diff --git a/.planning/debug/devices-loading-hang.md b/.planning/debug/devices-loading-hang.md new file mode 100644 index 0000000..50d684f --- /dev/null +++ b/.planning/debug/devices-loading-hang.md @@ -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] diff --git a/Dockerfile b/Dockerfile index e083f4a..c556ba2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,9 @@ RUN apk add --no-cache \ mpv-libs \ ffmpeg \ 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 . RUN pip install --no-cache-dir . diff --git a/lightsync/api/audio.py b/lightsync/api/audio.py index 8d6305d..0d01f61 100644 --- a/lightsync/api/audio.py +++ b/lightsync/api/audio.py @@ -1,13 +1,15 @@ """Audio loading and status endpoints.""" import asyncio +import shutil from pathlib import Path -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, HTTPException, Request, UploadFile, File from pydantic import BaseModel from lightsync.audio.waveform import extract_peaks router = APIRouter() SUPPORTED_EXTENSIONS = {".mp3", ".wav", ".flac", ".ogg"} +SHOWS_DIR = Path("/app/shows") class LoadRequest(BaseModel): @@ -48,6 +50,38 @@ async def get_audio_state(request: Request): 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") async def get_waveform(request: Request, peaks: int = 1000): """Get waveform peak data for the currently loaded audio file (AUD-05). diff --git a/lightsync/audio/engine.py b/lightsync/audio/engine.py index 7ca6d6a..056515f 100644 --- a/lightsync/audio/engine.py +++ b/lightsync/audio/engine.py @@ -1,10 +1,22 @@ """MPV-based audio engine — headless playback with 10Hz position polling.""" +import ctypes.util as _ctypes_util import sys import threading import time 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: diff --git a/lightsync/frontend/app.js b/lightsync/frontend/app.js index d4fe6ee..40b9a8e 100644 --- a/lightsync/frontend/app.js +++ b/lightsync/frontend/app.js @@ -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 = ''; + 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 () => { - const pathInput = document.getElementById('audio-path'); - const path = pathInput?.value.trim(); + const sel = document.getElementById('audio-select'); + const path = sel?.value; if (!path) return; try { 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); return; } - // Wait briefly for mpv to initialize, then fetch waveform setTimeout(loadWaveform, 500); } catch (err) { console.error('[audio] load error:', err); diff --git a/lightsync/frontend/index.html b/lightsync/frontend/index.html index b5c8e41..b56755d 100644 --- a/lightsync/frontend/index.html +++ b/lightsync/frontend/index.html @@ -72,8 +72,14 @@