feat(diarization-ui): multi-file audio upload with project create-or-select

- Upload page accepts multiple audio files at once
- Project field is a combobox: pick existing or type new name (auto-created)
- Document titles derived from filename without extension
- Files uploaded sequentially with progress bar
- New POST /projects/create-api endpoint (idempotent: returns existing if name matches)
- POST /upload now returns JSON {job_id} instead of HTML redirect

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 17:42:19 +01:00
parent 0c3aa4348b
commit 6924cc80fe

126
app.py
View File

@@ -470,32 +470,102 @@ def healthz():
@app.get("/", response_class=HTMLResponse)
def upload_page(msg: str = ""):
projects = get_projects()
opts = "".join([f"<option value='{p['id']}'>{p['name']}</option>" for p in projects])
existing_names = json.dumps([p["name"] for p in projects], ensure_ascii=False)
existing_map = json.dumps({p["name"]: p["id"] for p in projects}, ensure_ascii=False)
body = f"""
<div class='d-flex justify-content-between align-items-center mb-3'>
<h2 class='h4 mb-0'>Audio Upload</h2>
</div>
<p class='text-secondary'>Audio wird transkribiert + mit Sprechern angereichert und als Dokument gespeichert.</p>
<p class='text-secondary'>Mehrere Audiodateien gleichzeitig hochladen — je Datei wird ein Transkriptions-Job erstellt.</p>
{f"<div class='alert alert-info py-2'>{msg}</div>" if msg else ""}
<form action='/upload' method='post' enctype='multipart/form-data' class='card'>
<div class='row g-2'>
<div class='col-12 col-lg-4'>
<label class='form-label'>Projekt</label>
<select class='form-select' name='project_id'>{opts}</select>
<div class='card'>
<div class='row g-3 mb-3'>
<div class='col-12 col-md-5'>
<label class='form-label'>Projekt <span class='text-secondary fw-normal'>(bestehendes wählen oder neuen Namen eingeben)</span></label>
<input class='form-control' id='projectInput' list='projectList' placeholder='Projekt wählen oder anlegen …' autocomplete='off'>
<datalist id='projectList'></datalist>
</div>
<div class='col-12 col-lg-4'>
<label class='form-label'>Titel (optional)</label>
<input class='form-control' name='title' placeholder='z. B. Team-Call 23.03'>
</div>
<div class='col-12 col-lg-4'>
<label class='form-label'>Audio-Datei</label>
<input class='form-control' type='file' name='file' accept='audio/*' required>
<div class='col-12 col-md-7'>
<label class='form-label'>Audio-Dateien</label>
<input class='form-control' type='file' id='fileInput' accept='audio/*' multiple required>
</div>
</div>
<div class='mt-3'>
<button class='btn btn-primary' type='submit'>Verarbeiten & speichern</button>
<div id='filePreview' class='mb-3 d-none'>
<div class='text-secondary small mb-1'>Warteschlange:</div>
<ul id='fileList' class='list-group list-group-flush'></ul>
</div>
</form>
<button class='btn btn-primary' id='uploadBtn' onclick='startUpload()' disabled>
<i class='bi bi-cloud-upload'></i> <span id='uploadBtnLabel'>Hochladen & verarbeiten</span>
</button>
</div>
<div id='uploadProgress' class='d-none'>
<div class='progress mb-2' style='height:8px'>
<div class='progress-bar progress-bar-striped progress-bar-animated' id='progressBar' style='width:0%'></div>
</div>
<div id='progressStatus' class='text-secondary small'></div>
</div>
<script>
const _existingNames = {existing_names};
const _existingMap = {existing_map};
const datalist = document.getElementById('projectList');
_existingNames.forEach(n => {{ const o = document.createElement('option'); o.value = n; datalist.appendChild(o); }});
const fileInput = document.getElementById('fileInput');
const projectInput = document.getElementById('projectInput');
const uploadBtn = document.getElementById('uploadBtn');
function updateBtn() {{
uploadBtn.disabled = !(fileInput.files.length > 0 && projectInput.value.trim());
}}
fileInput.addEventListener('change', () => {{
updateBtn();
const list = document.getElementById('fileList');
list.innerHTML = '';
Array.from(fileInput.files).forEach(f => {{
const stem = f.name.replace(/\\.[^/.]+$/, '');
const li = document.createElement('li');
li.className = 'list-group-item py-1 small d-flex justify-content-between';
li.innerHTML = `<span><i class='bi bi-file-earmark-music'></i> ${{stem}}</span><span class='text-secondary'>${{(f.size/1024/1024).toFixed(1)}} MB</span>`;
list.appendChild(li);
}});
document.getElementById('filePreview').classList.toggle('d-none', fileInput.files.length === 0);
}});
projectInput.addEventListener('input', updateBtn);
async function startUpload() {{
const name = projectInput.value.trim();
if(!name || fileInput.files.length === 0) return;
uploadBtn.disabled = true;
document.getElementById('uploadProgress').classList.remove('d-none');
const files = Array.from(fileInput.files);
const bar = document.getElementById('progressBar');
const status = document.getElementById('progressStatus');
// resolve or create project
let projectId = _existingMap[name];
if(!projectId) {{
status.textContent = `Projekt "${{name}}" wird angelegt …`;
const r = await fetch('/projects/create-api', {{method:'POST', headers:{{'Content-Type':'application/x-www-form-urlencoded'}}, body: new URLSearchParams({{name}})}});
if(!r.ok) {{ alert('Fehler beim Anlegen des Projekts'); uploadBtn.disabled=false; return; }}
projectId = (await r.json()).id;
}}
for(let i=0; i<files.length; i++) {{
const f = files[i];
const stem = f.name.replace(/\\.[^/.]+$/, '');
status.textContent = `(${{i+1}}/${{files.length}}) ${{stem}} …`;
bar.style.width = (i / files.length * 100) + '%';
const fd = new FormData();
fd.append('project_id', projectId);
fd.append('file', f);
const r = await fetch('/upload', {{method:'POST', body: fd}});
if(!r.ok) {{ status.textContent = `Fehler bei ${{stem}}`; }}
}}
bar.style.width = '100%';
status.textContent = `${{files.length}} Datei(en) eingereicht — weiter zur Job-Übersicht …`;
setTimeout(() => location.href='/jobs', 800);
}}
</script>
"""
return layout("Upload", body)
@@ -530,24 +600,38 @@ def delete_project(project_id: int):
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
@app.post("/upload", response_class=HTMLResponse)
async def upload(project_id: int = Form(...), title: str = Form(""), file: UploadFile = File(...)):
@app.post("/projects/create-api")
def create_project_api(name: str = Form(...)):
name = name.strip()
if not name:
raise HTTPException(400, "Name darf nicht leer sein")
with db() as c:
existing = c.execute("SELECT id FROM projects WHERE name=?", (name,)).fetchone()
if existing:
return {"id": existing["id"]}
cur = c.execute("INSERT INTO projects(name, created_at) VALUES (?,?)", (name, now_iso()))
return {"id": cur.lastrowid}
@app.post("/upload")
async def upload(project_id: int = Form(...), file: UploadFile = File(...)):
data = await file.read()
if not data:
raise HTTPException(400, "Leere Datei")
JOB_DIR.mkdir(parents=True, exist_ok=True)
filename = (file.filename or "audio.bin").replace("/", "_")
stem = filename.rsplit(".", 1)[0] or filename
temp_path = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
temp_path.write_bytes(data)
job_id = enqueue_job(
"upload",
project_id=project_id,
title=(title or "").strip() or filename,
title=stem,
file_path=str(temp_path),
)
return HTMLResponse(f"<meta http-equiv='refresh' content='0; url=/jobs?queued={job_id}'>")
return {"job_id": job_id}
@app.get("/library", response_class=HTMLResponse)