refactor: split web.py — detector and audio-format helpers into isrweb/

Pure move, no behaviour change. web.py 1221 -> 807 lines.

The reason is not file size but isolation of change. CLAUDE.md records
that the next step for detection, should flagging ever need improving, is
a spectral filter or an optional Silero VAD over candidate sections —
that work now lands entirely in isrweb/loudness.py and never has to open
the HTTP layer. The byte-level format code (FLAC frame parsing, CRC-8,
live header patching) is a second self-contained cluster, touched only
when a container format misbehaves.

  isrweb/loudness.py   229  detector + its tuning constants
  isrweb/audiofile.py  250  filename clock, WAV/FLAC headers, MIME types
  web.py               807  cache, listing, handler, main()

Import direction is strictly web.py -> {loudness, audiofile}; the two
leaves import neither each other nor web.py, which is what lets the
detector be imported and tested without an HTTP server.

A package rather than flat modules because `audiofile` is a real PyPI
name and `detect` is generic enough to collide; a local file silently
shadows an installed one, which fails confusingly rather than loudly.
web.py stays at the repo root so `python web.py`, the docker-compose
command and the webui.html lookup are all unchanged.

Each module keeps its own optional numpy/soundfile try-import rather than
sharing one. That block cannot meaningfully diverge — if numpy is
installed every copy says True — and sharing it would stop isr.py being a
standalone file you can drop on a Pi and run.

Tests mirror the modules: test_loudness.py, test_audiofile.py, test_web.py
(+ unchanged test_isr.py). New tests/conftest.py owns the sys.path setup
that was previously an inline hack in test_isr.py — which only worked for
the other modules because that file happened to import first — and
provides the write_wav fixture the split would otherwise have duplicated.

Verified:
* 78 passed, same count as before the split; each test file also passes
  standalone.
* All 21 moved functions diffed against HEAD:web.py — byte-identical.
* Import graph asserted acyclic; both leaves import with `web` absent
  from sys.modules.
* An analysis cache written *before* the split still hits afterwards,
  with identical sections — the one way a "pure" move could quietly
  invalidate every user's cache.
* Endpoint smoke (analyze bounds, cache hit, cached_analysis in the
  listing, clip Content-Length, 8-bit rejection) and the byte-serving
  harness (ranges, download Range, HEAD, 405, live-header patching whole
  and straddling) both re-run green.
* Dockerfile COPY set verified by building the image's file tree in a
  temp dir and importing web.py with nothing else present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 23:14:54 +02:00
co-authored by Claude Opus 5
parent 138f6db0a5
commit c91264c253
12 changed files with 766 additions and 663 deletions
+35
View File
@@ -0,0 +1,35 @@
"""Tests for the filename clock in isrweb/audiofile.py.
Recordings are named for their *start* time, and that name is the only
reliable time anchor the archive has — mtime drifts to the last write.
"""
from isrweb.audiofile import _cut_filename, _recording_start
def test_recording_start_parses_standard_name():
from datetime import datetime
assert _recording_start("20260523_220000") == datetime(2026, 5, 23, 22, 0, 0)
def test_recording_start_rejects_nonstandard_name():
assert _recording_start("radio1_20260523") is None
assert _recording_start("notes") is None
def test_cut_filename_uses_wall_clock_span():
# Recording started 22:00:00; cut covers 22:31:30 → 22:32:30.
name = _cut_filename("20260523_220000", ".flac", 1890, 1950)
assert name == "20260523_22-31-30_22-32-30.flac"
def test_cut_filename_rolls_over_the_hour():
name = _cut_filename("20260523_220000", ".wav", 3590, 3661)
assert name == "20260523_22-59-50_23-01-01.wav"
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"