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:
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user