fix(02): Alpine libmpv/libsndfile symlinks, upload endpoint, browser file picker

- Dockerfile: add libmpv.so + libsndfile.so symlinks (Alpine/musl has no ldconfig)
- engine.py: monkey-patch ctypes.util.find_library as fallback for Alpine
- audio.py: add /upload (multipart) and /files endpoints
- pyproject.toml: add python-multipart
- frontend: replace path input with upload button + file selector dropdown
This commit is contained in:
Claude
2026-04-06 15:34:11 +00:00
parent 7f9efee47a
commit 9239988d38
7 changed files with 197 additions and 9 deletions

View File

@@ -0,0 +1,89 @@
---
status: awaiting_human_verify
trigger: "devices-loading-hang: Frontend shows loading... indefinitely under devices"
created: 2026-04-05T20:30:00Z
updated: 2026-04-05T21:10:00Z
---
## Current Focus
hypothesis: CONFIRMED — form.name.value silently throws TypeError. HTMLFormElement.name is the form's own IDL attribute (empty string ""), not the child input named "name". So form.name.value === "".value === undefined, then undefined.trim() throws TypeError caught silently by the catch block — loadDevices() never called after POST.
test: Access form fields via form.elements["name"] instead of form.name to avoid the HTMLFormElement own-property collision
expecting: POST succeeds and loadDevices() is called, device appears in list
next_action: Apply fix to app.js line 122 — change form.name.value to form.elements["name"].value
## Symptoms
expected: DEVICES panel renders the device list (empty or populated) after page load
actual: DEVICES panel stuck on "loading..." forever — no devices shown, no errors visible to user
errors: unknown — check browser-side JS and backend API response
reproduction: Open https://lightsync.groll.cloud (behind Authelia), look at DEVICES panel
started: After gap-closure plan 01-04 executed (added device CRUD form and apiPost/apiDelete helpers)
## Eliminated
- hypothesis: API endpoint not responding
evidence: docker logs show GET /api/devices returned 200 OK; devices.json volume-mounted correctly with one device record
timestamp: 2026-04-05T20:35:00Z
- hypothesis: JS throws before reaching loadDevices() call
evidence: Container app.js ends cleanly with loadDevices() as last statement — no form listener that could throw null
timestamp: 2026-04-05T20:40:00Z
- hypothesis: devices.json missing or malformed
evidence: /home/claude/led2/devices.json exists, is valid JSON, contains one device; mounted into container at /app/devices.json
timestamp: 2026-04-05T20:40:00Z
## Evidence
- timestamp: 2026-04-05T20:33:00Z
checked: docker logs lightsync --tail=50
found: Container is running; first page load shows GET /api/devices returned 200; second page load got HTML/CSS/JS but no /api/devices call
implication: API works correctly; second-load anomaly is likely Authelia redirect or browser cache behavior, not a code bug
- timestamp: 2026-04-05T20:35:00Z
checked: /home/claude/led2/devices.json
found: Valid JSON with one device record (sk6812, Test, 10.0.0.1:21324)
implication: Data layer is fine
- timestamp: 2026-04-05T20:38:00Z
checked: docker exec lightsync cat /app/lightsync/frontend/app.js
found: Container serves OLD app.js — no apiPost, no apiDelete, no removeDevice, no form submit listener, no device-row-info class
implication: Container was NOT rebuilt after 01-04 changes
- timestamp: 2026-04-05T20:39:00Z
checked: docker exec lightsync cat /app/lightsync/frontend/index.html
found: Container serves OLD index.html — no add-device-form, no panel IDs (devices-panel/animations-panel), no CRUD form
implication: Confirms stale build — local files changed but image not rebuilt
- timestamp: 2026-04-05T20:41:00Z
checked: volume mounts via docker inspect
found: Only shows/ and devices.json are volume-mounted; frontend/ is baked into the image
implication: Frontend changes require image rebuild — no bind mount shortcut available
- timestamp: 2026-04-05T20:43:00Z
checked: Old container app.js loadDevices() logic
found: Old loadDevices() correctly calls /api/devices and renders device rows. API returns 200. So the old version SHOULD show devices.
implication: ACTUAL hang cause: the new local index.html has "loading..." placeholder but the new app.js is NOT in the container, so loadDevices() IS called but the API returns devices with UUID id field serialized as string — old template renders fine. BUT: user is seeing "loading..." which means the USER'S BROWSER is getting the NEW index.html? No — browser gets what container serves, which is the OLD index.html.
- timestamp: 2026-04-05T20:44:00Z
checked: What old vs new app.js/index.html actually differ
found: Old app.js calls loadDevices() which should populate device-list. Old index.html has device-list div with "loading..." default. The API works and returns data. So the old version should NOT hang. HOWEVER: the new local index.html is NOT in the container. After rebuild, the new app.js will also be served. The "loading..." the user sees could be from the new index.html being served somehow, OR the old container has a bug we haven't caught yet.
implication: Need to verify what the browser actually receives — rebuild will fix regardless since it aligns local and container state
- timestamp: 2026-04-05T21:10:00Z
checked: app.js form submit handler line 122 — form.name.value
found: form.name accesses HTMLFormElement's own IDL .name attribute (returns "" since form has no name attr), not the child input named "name". "".value is undefined. undefined.trim() throws TypeError, caught by catch block silently — loadDevices() never runs.
implication: Root cause of "device not appearing after add". Fix: use form.elements["name"].value instead.
- timestamp: 2026-04-05T21:10:00Z
checked: removeDevice and DELETE path
found: removeDevice correctly calls apiDelete then loadDevices(). registry._devices keys are str(UUID), DELETE endpoint receives string path param, pop() uses same key. No bug in DELETE path.
implication: DELETE is correct — only POST submit handler has the form.name collision bug.
## Resolution
root_cause: Two bugs, same session. (1) Stale Docker image (now fixed by prior rebuild — device list loads). (2) form.name in the add-device form submit handler accesses HTMLFormElement's own IDL .name attribute (empty string "") instead of the child input element named "name". This makes form.name.value === undefined, undefined.trim() throws TypeError, caught silently by the catch block — so loadDevices() was never called after POST and the new device never appeared.
fix: Changed form.name.value.trim() to form.elements["name"].value.trim() in app.js line 122. Rebuilt and redeployed container.
verification: awaiting human confirmation — add device form should now work end-to-end
files_changed: [lightsync/frontend/app.js]

View File

@@ -7,7 +7,9 @@ RUN apk add --no-cache \
mpv-libs \ mpv-libs \
ffmpeg \ ffmpeg \
ffmpeg-libs \ ffmpeg-libs \
libsndfile libsndfile && \
ln -sf /usr/lib/libmpv.so.2 /usr/lib/libmpv.so && \
ln -sf /usr/lib/libsndfile.so.1 /usr/lib/libsndfile.so
COPY pyproject.toml . COPY pyproject.toml .
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .

View File

@@ -1,13 +1,15 @@
"""Audio loading and status endpoints.""" """Audio loading and status endpoints."""
import asyncio import asyncio
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from lightsync.audio.waveform import extract_peaks from lightsync.audio.waveform import extract_peaks
router = APIRouter() router = APIRouter()
SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"}
SHOWS_DIR = Path("/app/shows")
class LoadRequest(BaseModel): class LoadRequest(BaseModel):
@@ -48,6 +50,38 @@ async def get_audio_state(request: Request):
return engine.get_state() return engine.get_state()
@router.get("/files")
async def list_audio_files():
"""List audio files available in the shows directory."""
SHOWS_DIR.mkdir(parents=True, exist_ok=True)
files = sorted(
p.name for p in SHOWS_DIR.iterdir()
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
)
return {"files": files, "directory": str(SHOWS_DIR)}
@router.post("/upload")
async def upload_audio(request: Request, file: UploadFile = File(...)):
"""Upload an audio file to the shows directory and load it."""
engine = request.app.state.engine
if engine is None:
raise HTTPException(status_code=503, detail="Audio engine not ready")
suffix = Path(file.filename).suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"Unsupported format: {suffix}. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}")
SHOWS_DIR.mkdir(parents=True, exist_ok=True)
dest = SHOWS_DIR / file.filename
with dest.open("wb") as f:
shutil.copyfileobj(file.file, f)
await asyncio.to_thread(engine.load, str(dest))
return {"status": "loaded", "path": str(dest), "filename": file.filename}
@router.get("/waveform") @router.get("/waveform")
async def get_waveform(request: Request, peaks: int = 1000): async def get_waveform(request: Request, peaks: int = 1000):
"""Get waveform peak data for the currently loaded audio file (AUD-05). """Get waveform peak data for the currently loaded audio file (AUD-05).

View File

@@ -1,10 +1,22 @@
"""MPV-based audio engine — headless playback with 10Hz position polling.""" """MPV-based audio engine — headless playback with 10Hz position polling."""
import ctypes.util as _ctypes_util
import sys import sys
import threading import threading
import time import time
from typing import Any from typing import Any
import mpv # Alpine/musl: ctypes.util.find_library can't discover libmpv without ldconfig or objdump.
# Patch it to return the known path so python-mpv can load the library.
_orig_find_library = _ctypes_util.find_library
def _patched_find_library(name: str) -> str | None:
if name == "mpv":
return "/usr/lib/libmpv.so.2"
return _orig_find_library(name)
_ctypes_util.find_library = _patched_find_library
import mpv # noqa: E402
_ctypes_util.find_library = _orig_find_library # restore after mpv loaded
def apply_timer_fix() -> None: def apply_timer_fix() -> None:

View File

@@ -224,10 +224,55 @@ if (seekBar) {
}); });
} }
// Load audio button // Populate file selector
async function refreshFileList(selectValue) {
const sel = document.getElementById('audio-select');
if (!sel) return;
try {
const res = await fetch('/api/audio/files');
if (!res.ok) return;
const { files } = await res.json();
const prev = selectValue ?? sel.value;
sel.innerHTML = '<option value="">— select file —</option>';
files.forEach(name => {
const opt = document.createElement('option');
opt.value = `/app/shows/${name}`;
opt.textContent = name;
if (opt.value === prev) opt.selected = true;
sel.appendChild(opt);
});
} catch (e) {
console.error('[audio] file list error:', e);
}
}
refreshFileList();
// Upload audio file
document.getElementById('audio-upload')?.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/audio/upload', { method: 'POST', body: form });
if (!res.ok) {
const err = await res.json();
console.error('[audio] upload failed:', err.detail);
return;
}
const { path } = await res.json();
await refreshFileList(path);
setTimeout(loadWaveform, 500);
} catch (err) {
console.error('[audio] upload error:', err);
}
e.target.value = '';
});
// Load selected audio file
document.getElementById('btn-load')?.addEventListener('click', async () => { document.getElementById('btn-load')?.addEventListener('click', async () => {
const pathInput = document.getElementById('audio-path'); const sel = document.getElementById('audio-select');
const path = pathInput?.value.trim(); const path = sel?.value;
if (!path) return; if (!path) return;
try { try {
const res = await fetch('/api/audio/load', { const res = await fetch('/api/audio/load', {
@@ -240,7 +285,6 @@ document.getElementById('btn-load')?.addEventListener('click', async () => {
console.error('[audio] load failed:', err.detail); console.error('[audio] load failed:', err.detail);
return; return;
} }
// Wait briefly for mpv to initialize, then fetch waveform
setTimeout(loadWaveform, 500); setTimeout(loadWaveform, 500);
} catch (err) { } catch (err) {
console.error('[audio] load error:', err); console.error('[audio] load error:', err);

View File

@@ -72,8 +72,14 @@
<input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider"> <input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider">
</div> </div>
<div class="transport-file"> <div class="transport-file">
<input type="text" id="audio-path" placeholder="Audio file path on server..." class="audio-path-input"> <label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
<button id="btn-load" class="transport-btn" title="Load audio">LOAD</button> + UPLOAD
<input type="file" id="audio-upload" accept=".mp3,.wav,.flac,.ogg" style="display:none;">
</label>
<select id="audio-select" class="audio-path-input">
<option value="">— no files yet, upload first —</option>
</select>
<button id="btn-load" class="transport-btn" title="Load selected file">LOAD</button>
</div> </div>
</footer> </footer>

View File

@@ -16,6 +16,7 @@ dependencies = [
"python-mpv>=1.0.5", "python-mpv>=1.0.5",
"soundfile>=0.12.0", "soundfile>=0.12.0",
"numpy>=1.26.0", "numpy>=1.26.0",
"python-multipart>=0.0.9",
] ]
[project.optional-dependencies] [project.optional-dependencies]