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

@@ -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).

View File

@@ -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:

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 () => {
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);

View File

@@ -72,8 +72,14 @@
<input type="range" id="seek-bar" min="0" max="100" value="0" step="0.1" class="seek-slider">
</div>
<div class="transport-file">
<input type="text" id="audio-path" placeholder="Audio file path on server..." class="audio-path-input">
<button id="btn-load" class="transport-btn" title="Load audio">LOAD</button>
<label class="transport-btn" title="Upload MP3/WAV/FLAC/OGG from your computer" style="cursor:pointer;">
+ 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>
</footer>