fix: separate analyses dir so caching works with read-only recordings mount
The Docker web container mounts ./recordings as :ro, causing every cache write to fail silently (PermissionError swallowed by bare except). Fix: add --analyses-dir flag (default: <recordings>/analyses for local runs). docker-compose.yml adds ./analyses:/analyses (writable) and passes --analyses-dir /analyses to web.py. Cache write failures now print a warning instead of being swallowed silently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -152,6 +152,7 @@ python web.py --dir /path/to/audio # custom recordings directory
|
|||||||
python web.py --port 8888 # custom port
|
python web.py --port 8888 # custom port
|
||||||
python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
python web.py --threshold 0.03 # loudness threshold 0–1 (default 0.05)
|
||||||
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
|
python web.py --min-gap 15 # grace period in seconds for merging loud sections (default 2)
|
||||||
|
python web.py --analyses-dir /path/to/dir # where to store analysis cache files (default: <recordings>/analyses)
|
||||||
```
|
```
|
||||||
|
|
||||||
Shows recordings grouped by day with collapsible sections. Features:
|
Shows recordings grouped by day with collapsible sections. Features:
|
||||||
@@ -211,6 +212,8 @@ docker compose down && docker compose up -d --build
|
|||||||
|
|
||||||
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
**Log file in Docker:** The recorder always logs to stdout, so `docker compose logs -f` shows live output. To persist logs on the host, set `log_file = /app/recordings/recorder.log` in `config.ini` (the `recordings` directory is the bind mount).
|
||||||
|
|
||||||
|
**Analysis cache in Docker:** The web container mounts `./recordings` read-only, so analysis cache files are written to a separate `./analyses` bind mount (mapped to `/analyses` inside the container). This directory is created automatically by Docker Compose on first run. Cache files are stored as `analyses/<filename>.analysis.json` on the host.
|
||||||
|
|
||||||
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
|
**File retention:** Individual recordings can be deleted from the web UI. For bulk / automated cleanup, add a cron job on the host:
|
||||||
```bash
|
```bash
|
||||||
# Delete recordings older than 30 days
|
# Delete recordings older than 30 days
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
volumes:
|
volumes:
|
||||||
- ./recordings:/recordings:ro
|
- ./recordings:/recordings:ro
|
||||||
|
- ./analyses:/analyses
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["python", "web.py", "--dir", "/recordings"]
|
command: ["python", "web.py", "--dir", "/recordings", "--analyses-dir", "/analyses"]
|
||||||
|
|||||||
@@ -207,19 +207,18 @@ def analyze_flac(path: Path, window_samples: int = WINDOW_SAMPLES,
|
|||||||
# Analysis cache helpers
|
# Analysis cache helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _analysis_cache_path(base: Path, audio_path: Path) -> Path:
|
def _analysis_cache_path(analyses_base: Path, recordings_base: Path, audio_path: Path) -> Path:
|
||||||
rel = audio_path.relative_to(base)
|
rel = audio_path.relative_to(recordings_base)
|
||||||
return base / 'analyses' / rel.parent / (rel.name + '.analysis.json')
|
return analyses_base / rel.parent / (rel.name + '.analysis.json')
|
||||||
|
|
||||||
|
|
||||||
def prune_orphan_analyses(base: Path):
|
def prune_orphan_analyses(analyses_base: Path, recordings_base: Path):
|
||||||
analyses_dir = base / 'analyses'
|
if not analyses_base.exists():
|
||||||
if not analyses_dir.exists():
|
|
||||||
return
|
return
|
||||||
removed = 0
|
removed = 0
|
||||||
for cache in analyses_dir.rglob('*.analysis.json'):
|
for cache in analyses_base.rglob('*.analysis.json'):
|
||||||
rel = cache.relative_to(analyses_dir)
|
rel = cache.relative_to(analyses_base)
|
||||||
audio_path = base / rel.parent / rel.name[:-len('.analysis.json')]
|
audio_path = recordings_base / rel.parent / rel.name[:-len('.analysis.json')]
|
||||||
if not audio_path.exists():
|
if not audio_path.exists():
|
||||||
try:
|
try:
|
||||||
cache.unlink()
|
cache.unlink()
|
||||||
@@ -283,6 +282,7 @@ def list_files(recordings_dir: str):
|
|||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
recordings_dir: str = 'recordings'
|
recordings_dir: str = 'recordings'
|
||||||
|
analyses_dir: str = 'recordings/analyses'
|
||||||
threshold: float = LOUD_THRESHOLD
|
threshold: float = LOUD_THRESHOLD
|
||||||
min_gap: float = MIN_GAP_SECONDS
|
min_gap: float = MIN_GAP_SECONDS
|
||||||
|
|
||||||
@@ -358,8 +358,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
base = Path(self.recordings_dir).resolve()
|
recordings_base = Path(self.recordings_dir).resolve()
|
||||||
cache_path = _analysis_cache_path(base, path)
|
analyses_base = Path(self.analyses_dir).resolve()
|
||||||
|
cache_path = _analysis_cache_path(analyses_base, recordings_base, path)
|
||||||
try:
|
try:
|
||||||
cached = json.loads(cache_path.read_text('utf-8'))
|
cached = json.loads(cache_path.read_text('utf-8'))
|
||||||
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap:
|
if cached.get('threshold') == threshold and cached.get('min_gap') == min_gap:
|
||||||
@@ -386,8 +387,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
tmp = cache_path.with_suffix('.tmp')
|
tmp = cache_path.with_suffix('.tmp')
|
||||||
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
tmp.write_text(json.dumps({'threshold': threshold, 'min_gap': min_gap, 'result': result}), 'utf-8')
|
||||||
os.replace(tmp, cache_path)
|
os.replace(tmp, cache_path)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
print(f'Warning: could not write analysis cache {cache_path}: {e}', flush=True)
|
||||||
|
|
||||||
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
|
self._send(200, json.dumps(result).encode('utf-8'), 'application/json')
|
||||||
|
|
||||||
@@ -512,7 +513,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_analysis_cache_path(Path(self.recordings_dir).resolve(), path).unlink()
|
_analysis_cache_path(
|
||||||
|
Path(self.analyses_dir).resolve(),
|
||||||
|
Path(self.recordings_dir).resolve(),
|
||||||
|
path,
|
||||||
|
).unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1639,23 +1644,29 @@ def main():
|
|||||||
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
help=f'RMS loudness threshold 0–1 (default: {LOUD_THRESHOLD})')
|
||||||
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
|
parser.add_argument('--min-gap', type=float, default=MIN_GAP_SECONDS,
|
||||||
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
help=f'Seconds gap for merging loud sections (default: {MIN_GAP_SECONDS})')
|
||||||
|
parser.add_argument('--analyses-dir', default=None,
|
||||||
|
help='Directory for analysis cache files (default: <recordings-dir>/analyses)')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
rec_dir = Path(args.dir)
|
rec_dir = Path(args.dir).resolve()
|
||||||
if not rec_dir.exists():
|
analyses_dir = Path(args.analyses_dir).resolve() if args.analyses_dir else rec_dir / 'analyses'
|
||||||
print(f"Warning: recordings directory '{args.dir}' does not exist yet.")
|
|
||||||
|
|
||||||
prune_orphan_analyses(rec_dir.resolve())
|
if not rec_dir.exists():
|
||||||
|
print(f"Warning: recordings directory '{rec_dir}' does not exist yet.")
|
||||||
|
|
||||||
|
prune_orphan_analyses(analyses_dir, rec_dir)
|
||||||
|
|
||||||
class Handler(_Handler):
|
class Handler(_Handler):
|
||||||
recordings_dir = str(rec_dir.resolve())
|
recordings_dir = str(rec_dir)
|
||||||
|
analyses_dir = str(analyses_dir)
|
||||||
threshold = args.threshold
|
threshold = args.threshold
|
||||||
min_gap = args.min_gap
|
min_gap = args.min_gap
|
||||||
|
|
||||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
|
||||||
print(f"ISR Web running → http://{args.host}:{args.port}/")
|
print(f"ISR Web running → http://{args.host}:{args.port}/")
|
||||||
print(f"Recordings dir → {rec_dir.resolve()}")
|
print(f"Recordings dir → {rec_dir}")
|
||||||
|
print(f"Analyses dir → {analyses_dir}")
|
||||||
print(f"Loud threshold → {args.threshold}")
|
print(f"Loud threshold → {args.threshold}")
|
||||||
if not NUMPY_AVAILABLE:
|
if not NUMPY_AVAILABLE:
|
||||||
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
|
print("Note: numpy not installed — WAV RMS uses pure Python (slower); FLAC analysis unavailable")
|
||||||
|
|||||||
Reference in New Issue
Block a user