Add docker compose deployment template

Multi-stage image built from the `web` extra only, so it carries no Qt: the
whole web path (webapp, web_session, metrics_json, and the shared analysis
modules) imports no Qt, pyqtgraph or matplotlib canvas. The build stage has the
compiler scipy/numba need; the runtime layer installs the built wheel, so the
same image works from a checkout or a release tarball.

Compose declares no audio volume on purpose. The app keeps no audio, so there is
nothing to persist: `read_only` plus a /tmp tmpfs means request spools are
memory-backed and never reach a disk. Publishing is loopback-only because the
tool has no authentication, and BLAS thread pools are pinned because analysis is
CPU-bound.

Fix three packaging bugs the build surfaced, each of which broke the image:

- `pip install --extra web` is not valid for a source tree; uv wants `.[web]`
  and failed with "Requesting extras requires a pylock.toml".
- The wheel shipped no static assets at all. `package-data` keys match
  *packages*, and the app modules are top-level `py-modules`, so the `"*"` key
  matched nothing - verified by inspecting the built wheel: 14 .py files, zero
  HTML/JS/CSS. An installed `ujm-web` would have 404'd every asset. `static/` is
  now declared a package (hence its packaging-shim `__init__.py`) so the assets
  install next to the modules, which is where `webapp.py` looks for them.
- The Dockerfile's curated COPY list omitted modules that `py-modules` requires,
  failing the build with "file <module>.py not found". It copies the checkout,
  filtered by .dockerignore.

Move PyQt5/pyqtgraph into a `desktop` extra so the web install is genuinely
Qt-free. Verified: `find` for Qt in a web-only venv now returns nothing, and the
desktop path still resolves and imports.

Add scripts/make_fixtures.py so the smoke suite has no network or ffmpeg
dependency (the tagged MP3 is optional and its assertions skip when absent), and
make the suite tolerate that. Verified against the *installed package* running
the container's own command line, from a directory with no source tree:
61/61 passing.

Also correct README claims that had gone stale: BPM (removed), the matplotlib
canvas (replaced by pyqtgraph), and font_control_widget.py (deleted).
This commit is contained in:
2026-09-14 01:57:00 +09:00
parent 5df802a574
commit a1fed2cd71
9 changed files with 432 additions and 47 deletions
+29
View File
@@ -0,0 +1,29 @@
# Build context hygiene. The image needs the source modules and static/, but
# nothing else — a fat context here means slow builds and a bigger surface for
# accidentally baking in local state (audio, caches, .env, ssh keys).
.git
.gitignore
.venv
__pycache__/
*.pyc
*.pyo
*.egg-info/
build/
dist/
# Tests and tooling are not needed to run the service.
tests/
scripts/
.claude/
.vscode/
.idea/
# Local state that must never enter an image.
*.log
.env
.env.*
fonts/
*.ttf
*.otf
*.ttc
audio_analysis.log
+88
View File
@@ -0,0 +1,88 @@
# uj-mastering-master — web UI
#
# Multi-stage on purpose: the build stage compiles wheels (librosa pulls in
# scipy, numba and llvmlite, all of which have C extensions), and none of that
# toolchain belongs in the runtime image.
#
# The runtime image installs the *installed package*, not a source tree, so the
# same image works whether you built it from a checkout or a release tarball.
#
# Note on audio: the web UI stores uploaded audio in the browser (OPFS) and
# spools each request body to /tmp only for the duration of that request. There
# is no volume for audio, because the app never keeps any — see README.
#
# Note on the desktop app: this image is the *web* front end only. PyQt5 and
# pyqtgraph are deliberately not installed — there is no display in a container.
# ---- build -----------------------------------------------------------------
FROM python:3.11-slim AS build
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1 \
UV_LINK_MODE=copy
# numba/llvmlite and scipy need a compiler; libsndfile is what soundfile
# (librosa's decoder) links against at runtime, but the wheel needs it at build
# time to be detected.
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv
WORKDIR /src
# Copy the whole checkout minus .dockerignore. Deliberately not a hand-picked
# file list: `py-modules` in pyproject.toml enumerates every module the build
# backend must find, and a curated COPY that omits any of them fails the build
# with "file <module>.py not found". The exclusions that matter (venv, caches,
# .git, local state) are in .dockerignore, which is the right place for them.
COPY . .
# Build a wheel, then install it into a self-contained prefix to copy forward.
# `"uj-mastering-master[web]"` (not `--extra web`) is the syntax uv wants when
# installing a source tree — `--extra` needs a lockfile/setup.py it does not
# have here and fails with "Requesting extras requires a pylock.toml...".
RUN uv venv /opt/venv \
&& uv pip install --python /opt/venv/bin/python --no-cache \
".[web]"
# ---- runtime ---------------------------------------------------------------
FROM python:3.11-slim AS runtime
# libsndfile1 is the only runtime system dependency: librosa decodes through
# soundfile, which dlopens it. Everything else is a self-contained wheel.
# (FFmpeg is *not* needed — it only ever generated test fixtures.)
RUN apt-get update && apt-get install -y --no-install-recommends \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
# Non-root, with a home it can actually write to: librosa/numba want a cache
# dir, and the app spools request bodies under /tmp.
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin ujm
COPY --from=build /opt/venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HOME=/home/ujm \
NUMBA_CACHE_DIR=/tmp/numba-cache \
MPLCONFIGDIR=/tmp/matplotlib
# The app's temporary spools land here. Declared so a read-only root filesystem
# can still be used (`read_only: true` + `tmpfs: /tmp` in compose).
VOLUME ["/tmp"]
USER ujm
WORKDIR /home/ujm
EXPOSE 8000
# Baked-in healthcheck: hit the API rather than the page, so it fails when the
# *backend* is broken and not merely when a static asset is missing.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
# 0.0.0.0, not the CLI default of 127.0.0.1: loopback inside a container is
# unreachable from the host, which is the whole point of publishing a port.
CMD ["ujm-web", "--host", "0.0.0.0", "--port", "8000", "--log-level", "INFO"]
+66 -18
View File
@@ -20,11 +20,11 @@ Developed with Claude Code assistance.
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
- **Native sample rate**: audio is loaded without resampling, so the full band
(up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed
- **BPM detection** via librosa
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored) and system fallbacks; the UI is pinned to M PLUS 1 Code @ 10pt
- **Background analysis thread** so the UI stays responsive; metric switches
compute off the GUI thread and cache, so re-selecting a metric is instant
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
- **pyqtgraph renderer**: one persistent `PlotItem` reused across replots, with
per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis)
### Roadmap
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
@@ -35,12 +35,17 @@ dynamic range (DR meter), plot-style controls, interactive axis controls.
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
```bash
uv sync
uv sync --extra desktop
uv run ujm
```
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
`uv sync` on its own installs only the analysis dependencies (librosa, numpy,
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
`desktop` extra, kept separate because nothing on the web path imports it. Add
`--all-extras` to get both front ends.
### Logging flags
```bash
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
@@ -65,6 +70,9 @@ uv run ujm-web # http://127.0.0.1:8000
uv run ujm-web --host 0.0.0.0 --port 8080 --open
```
A server install needs no Qt at all, which is also what keeps the container
image small.
Everything the desktop app does works the same way: all seven metrics, the
overlay, lin/log toggles, absolute/relative time, and draggable reference lines
(kept per metric, as on the desktop).
@@ -86,19 +94,57 @@ mid-request.
The front end is served entirely from `static/` with uPlot vendored locally, so
nothing is fetched from a CDN, and the server binds loopback by default.
### Deploying with Docker
A `docker compose` template is provided:
```bash
docker compose up --build # build and start
open http://localhost:8000
```
The image is `web` extra only — no Qt, no display server — and runs as an
unprivileged user (uid 10001). It is built multi-stage so the compiler toolchain
that scipy/numba need never reaches the runtime layer; the runtime image installs
the built wheel, so the same image works from a checkout or a release tarball.
What the template assumes, and why:
- **There is no audio volume.** Not an oversight: the app keeps no audio, so
there is nothing to persist. `read_only: true` with a `/tmp` tmpfs means
request spools are memory-backed and never touch a disk at all.
- **Loopback publishing.** The app binds `0.0.0.0` inside the container (the CLI
default of `127.0.0.1` would be unreachable from the host), but compose
publishes `127.0.0.1:8000:8000`. This tool has no authentication, so it should
not appear on the LAN by accident. Change to `"8000:8000"` or put it behind a
reverse proxy to expose it deliberately.
- **CPU limits, not just memory.** Analysis is CPU-bound numpy/scipy, so the BLAS
thread pools are pinned (`OMP_NUM_THREADS` etc.) to stop overlapping requests
from oversubscribing. Raise them if you raise `cpus`.
- `cap_drop: [ALL]` and `no-new-privileges` — nothing in the image needs
capabilities.
For a read-only root filesystem with no writable layer at all, the defaults
already give you that.
### Verifying it
With a server running:
```bash
python3 scripts/webapi_smoke.py # 61 assertions, needs ffmpeg for the fixtures
python3 scripts/make_fixtures.py # synthesise test audio
python3 scripts/webapi_smoke.py # 61 assertions
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
```
The suite drives the real HTTP surface with real audio and checks the contract
the browser depends on — payload shapes per metric, physics cross-checked
against an independent `librosa` computation, view toggles that must not alter
data, error paths, and that no spool file survives a request.
`make_fixtures.py` generates the audio the suite needs (`sine440.wav`,
`pinknoise.wav`) with numpy alone, so no network and no ffmpeg are required — a
tagged MP3 is added only if ffmpeg happens to be present, and the tag assertions
skip cleanly when it is not. The suite drives the real HTTP surface with real
audio and checks the contract the browser depends on — payload shapes per
metric, physics cross-checked against an independent `librosa` computation, view
toggles that must not alter data, error paths, and that no spool file survives a
request.
### Web architecture
@@ -111,11 +157,12 @@ data, error paths, and that no spool file survives a request.
| `static/app.js` | Web renderer: same `PlotSpec` drawn with uPlot + canvas |
## Dependencies
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
through `uv.lock`. Python 3.10+.
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
`pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
(`uv sync --extra web`).
Optional extras:
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
## Architecture
@@ -123,12 +170,13 @@ The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
| --- | --- |
| `main.py` | `MainWindow` + the `ujm` entry point |
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
| `master_core.py` | `AudioFile`: native-rate librosa loading, RMS rolling window, BPM |
| `master_core.py` | `AudioFile`: native-rate librosa loading, rolling RMS |
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
| `font_control_widget.py` | Font picker + size slider |
| `plot_control_widget.py` | Metric selector + refresh-plot button |
| `audio_visualization_widget.py` | pyqtgraph `PlotItem` host; renders `PlotSpec` |
| `plotspec.py` | Renderer-neutral plot description (`Curve`/`Band`/`HLine`/`Heatmap`) |
| `font_manager.py` | Custom + system CJK font discovery, Qt font config |
| `plot_control_widget.py` | Metric selector, log-frequency toggle, time-axis mode |
| `ref_line_widget.py` | Reference-line control; values kept per metric, in its own units |
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
| `setup_fonts.py` | Diagnostic utility (run standalone) |
+88
View File
@@ -0,0 +1,88 @@
# uj-mastering-master — web UI, container deployment template.
#
# docker compose up --build # build and start
# docker compose logs -f # watch startup
# open http://localhost:8000
#
# There is deliberately no audio volume. Uploaded audio lives in the browser
# (Origin Private File System) and each request body is spooled to /tmp only
# while that request is being decoded. Nothing about a user's masters is
# persisted here — read-only storage is not just possible, it is the intent.
services:
ujm-web:
build:
context: .
dockerfile: Dockerfile
# Pin the build to this branch's source; override to build from a
# release tag instead:
# docker compose build --build-arg ...
image: uj-mastering-master-web:local
container_name: ujm-web
# Loopback-only by default. The app binds 0.0.0.0 *inside* the container,
# but publishing to 127.0.0.1 means it is only reachable from this host —
# an analysis tool with no authentication should not appear on the LAN by
# accident. Change to "8000:8000" (or put it behind a reverse proxy) to
# expose it deliberately.
ports:
- "127.0.0.1:8000:8000"
environment:
# Analysis is CPU-bound numpy/scipy; keep BLAS from oversubscribing when
# several requests overlap. Raise these if you raise `cpus` below.
OMP_NUM_THREADS: "4"
OPENBLAS_NUM_THREADS: "4"
MKL_NUM_THREADS: "4"
NUMEXPR_NUM_THREADS: "4"
# Writable scratch for numba/matplotlib caches and request spools.
NUMBA_CACHE_DIR: /tmp/numba-cache
MPLCONFIGDIR: /tmp/matplotlib
TMPDIR: /tmp
# The container filesystem can be read-only: the app writes nothing it
# needs to keep. /tmp is a tmpfs (memory-backed) so spooled request bodies
# never touch a disk at all.
read_only: true
tmpfs:
- /tmp:size=1g,mode=1777
# Analysis needs headroom: a long master at native sample rate is heavy in
# RAM. Raise the limit for large files; the tmpfs above is also backed by
# memory, so --memory must comfortably exceed it.
deploy:
resources:
limits:
cpus: "4.0"
memory: 4g
# Run as the image's unprivileged user (uid 10001). Nothing here needs root.
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
restart: unless-stopped
stop_grace_period: 10s
# The image ships a HEALTHCHECK; this is the same check expressed at the
# orchestration layer, so `docker compose ps` shows real health.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# No volumes are declared, on purpose. If you want to run the API smoke test
# inside the image, do it ad hoc rather than mounting a source tree:
#
# docker compose exec ujm-web python - <<'PY'
# ... # or see `make smoke` in the README
# PY
+45 -17
View File
@@ -6,12 +6,27 @@ build-backend = "setuptools.build_meta"
name = "uj-mastering-master"
version = "0.1.0"
requires-python = ">=3.10"
# Only what the *analysis* needs. The GUI stack lives in the `desktop` extra
# below, because nothing on the web path touches it: `webapp.py`,
# `web_session.py`, `metrics_json.py`, `master_core.py`, `metrics.py` and
# `plotspec.py` import no Qt, no pyqtgraph and no matplotlib canvas. Keeping
# PyQt5 here would force a `--headless`-style install to drag in a full Qt
# stack it never loads.
dependencies = [
"librosa",
"numpy",
"matplotlib",
"matplotlib", # librosa dependency, and colormaps for the colormap helper
"mutagen",
"pyloudnorm",
]
license = {text = "Apache-2.0"}
[project.optional-dependencies]
# Desktop GUI. Installed with:
# uv sync --extra desktop (or `uv sync --all-extras`)
desktop = [
"PyQt5>=5.15.10",
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
# versions are Linux/macOS only.
@@ -19,19 +34,27 @@ dependencies = [
"pyqtgraph>=0.14.0",
]
license = {text = "Apache-2.0"}
[project.optional-dependencies]
# Web front end (the `feature/web-ui` branch). Installed with:
# Web front end. Installed with:
# uv sync --extra web
# Deliberately separate from the desktop path: a server install of the web UI
# needs no Qt/wheel stack, and a desktop install needs no ASGI stack.
# needs no Qt/wheel stack. Which is what makes the container image small and
# lets it run without a display server at all.
web = [
"fastapi>=0.110",
"uvicorn[standard]>=0.27",
"python-multipart>=0.0.9", # multipart upload parsing for FastAPI
]
[tool.uv]
# Note: `default-groups` cannot name an entry from `[project.optional-dependencies]`
# — it only accepts `[dependency-groups]` names, and errors with "Default group
# `desktop` ... is not defined in the project's `dependency-groups` table".
# Extras must be requested explicitly, which is why the README spells out both:
# uv sync # analysis + nothing else
# uv sync --extra desktop # + Qt/pyqtgraph (the GUI; `uv run ujm`)
# uv sync --extra web # + fastapi/uvicorn (`uv run ujm-web`)
# uv sync --all-extras # both
[project.scripts]
ujm = "main:main"
ujm-web = "webapp:main"
@@ -54,16 +77,21 @@ py-modules = [
"web_session",
"metrics_json",
]
# `static/` is declared a *package* so `package-data` has something to attach
# to. `package-data` keys match packages, and top-level `py-modules` are not
# packages — so the earlier `"*" = [...]` form silently matched nothing and the
# built wheel contained no assets at all (verified: 14 .py files, zero HTML/
# JS/CSS). An installed `ujm-web` therefore 404'd every asset. With this entry
# the assets install to `site-packages/static/`, which is exactly where
# `webapp.py` looks (`Path(__file__).parent / "static"`).
packages = ["static"]
[tool.setuptools.package-data]
# Vendored browser assets: uPlot (curves), no CDN at runtime. `webapp.py`
# resolves them relative to `__file__`, so on an installed wheel they must sit
# next to the modules — see `[tool.setuptools.data-files]` for the sdist path.
"*" = ["static/*"]
[tool.setuptools.data-files]
# The web assets live next to the *modules*, not in site-packages/static, so a
# wheel install needs them copied into the package directory. `MANIFEST.in`
# keeps them in the sdist; running from a checkout (the documented path) needs
# neither.
"lib/python-static" = ["static/*"]
# Vendored browser assets: uPlot (curves), no CDN at runtime. Keyed on the
# `static` package (declared above) rather than `"*"`: a `"*"` key matches
# *packages*, and there are none besides `static` — the app modules are
# top-level `py-modules`, which is why the original form shipped nothing.
#
# `**/*` rather than `*` so the glob reaches `static/vendor/`, where the uPlot
# bundle lives.
static = ["*", "**/*"]
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Generate the audio fixtures `webapi_smoke.py` expects.
Synthesised rather than downloaded so the smoke test has no network dependency
and no external tools — `soundfile` and `numpy` come in with `librosa`, so this
runs in the container image as-is (no ffmpeg needed).
sine440.wav 12 s, 440 Hz, stereo, -12 dBFS — a known-tone reference
pinknoise.wav 8 s, pink noise, stereo — broadband, exercises the STFT
tagged.mp3 a tagged file — only if ffmpeg is available
Usage: python3 scripts/make_fixtures.py [output_dir] (default /tmp/ujm-test)
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
import numpy as np
import soundfile as sf
SR = 44100
def sine(path: Path, seconds: float = 12.0, hz: float = 440.0,
dbfs: float = -12.0) -> None:
"""A tone at a known level. Peak is exactly 10**(dbfs/20) before encoding."""
t = np.arange(int(seconds * SR)) / SR
mono = (10 ** (dbfs / 20)) * np.sin(2 * np.pi * hz * t)
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
def pink(path: Path, seconds: float = 8.0, peak: float = 0.25) -> None:
"""Pink noise via 1/sqrt(f) shaping, normalised to a known peak."""
rng = np.random.default_rng(7)
n = int(seconds * SR)
spectrum = np.fft.rfft(rng.standard_normal(n))
freqs = np.fft.rfftfreq(n, 1 / SR)
freqs[0] = freqs[1] # avoid dividing by zero at DC
shaped = np.fft.irfft(spectrum / np.sqrt(freqs), n)
mono = peak * shaped / np.max(np.abs(shaped))
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
def tagged_mp3(path: Path) -> bool:
"""An MP3 with ID3 tags, so the tag-reading path is exercised.
Genuinely optional: MP3 encoding is the one thing numpy cannot do, so this
needs ffmpeg and is skipped when it is absent. The suite's tag assertions
adapt — see `webapi_smoke.py`.
"""
if shutil.which("ffmpeg") is None:
return False
subprocess.run(
["ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi", "-i", "sine=frequency=330:duration=6:sample_rate=44100",
"-metadata", "artist=Test Artist", "-metadata", "title=Test Title",
"-codec:a", "libmp3lame", "-q:a", "2", str(path)],
check=True,
)
return True
def main() -> int:
out = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/ujm-test")
out.mkdir(parents=True, exist_ok=True)
sine(out / "sine440.wav")
pink(out / "pinknoise.wav")
mp3 = tagged_mp3(out / "テスト.mp3")
for f in sorted(out.iterdir()):
print(f" {f.name} {f.stat().st_size / 1024:.0f} KiB")
if not mp3:
print("\nnote: no ffmpeg, so no tagged MP3 fixture was written "
"(tag assertions will be skipped)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8 -1
View File
@@ -116,11 +116,18 @@ def main() -> int:
sine = next(r for n, r in uploads.items() if "sine440" in n)
noise = next(r for n, r in uploads.items() if "pinknoise" in n)
tagged = next(r for n, r in uploads.items() if r["file"]["song_name"].startswith("Test Artist"))
# The tagged MP3 needs an encoder to create, so it may be absent on a machine
# without ffmpeg (e.g. the container image). Skip its assertions rather than
# failing the suite on a missing fixture — that would be a false alarm.
tagged = next((r for r in uploads.values()
if r["file"]["song_name"].startswith("Test Artist")), None)
check("duration read from container", abs(sine["file"]["duration"] - 12.0) < 0.05,
f"{sine['file']['duration']}s")
check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100)
if tagged is None:
print("SKIP ID3 tag assertions — no tagged MP3 fixture (needs ffmpeg)")
else:
check("ID3 tags resolve to a song name",
tagged["file"]["song_name"] == "Test Artist - Test Title",
tagged["file"]["song_name"])
+11
View File
@@ -0,0 +1,11 @@
"""Packaging shim — not an importable Python module.
`static/` holds the web UI's browser assets (HTML, JS, CSS, vendored uPlot).
It is declared as a setuptools *package* purely so `[tool.setuptools.package-data]`
has a package to attach the assets to. Without this file the directory is not a
package, `package-data` matches nothing, and the built wheel ships without any
assets at all — leaving an installed `ujm-web` serving 404s for every file.
Nothing imports it. `webapp.py` reads the directory from disk via
`Path(__file__).parent / "static"`, so the empty module has no runtime role.
"""
Generated
+8 -6
View File
@@ -2046,12 +2046,14 @@ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "pyloudnorm" },
]
[package.optional-dependencies]
desktop = [
{ name = "pyqt5" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
{ name = "pyqtgraph" },
]
[package.optional-dependencies]
web = [
{ name = "fastapi" },
{ name = "python-multipart" },
@@ -2066,13 +2068,13 @@ requires-dist = [
{ name = "mutagen" },
{ name = "numpy" },
{ name = "pyloudnorm" },
{ name = "pyqt5", specifier = ">=5.15.10" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
{ name = "pyqtgraph", specifier = ">=0.14.0" },
{ name = "pyqt5", marker = "extra == 'desktop'", specifier = ">=5.15.10" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32' and extra == 'desktop'", specifier = "==5.15.2" },
{ name = "pyqtgraph", marker = "extra == 'desktop'", specifier = ">=0.14.0" },
{ name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.27" },
]
provides-extras = ["web"]
provides-extras = ["desktop", "web"]
[[package]]
name = "urllib3"