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:
126
app.py
126
app.py
@@ -470,32 +470,102 @@ def healthz():
|
|||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def upload_page(msg: str = ""):
|
def upload_page(msg: str = ""):
|
||||||
projects = get_projects()
|
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"""
|
body = f"""
|
||||||
<div class='d-flex justify-content-between align-items-center mb-3'>
|
<div class='d-flex justify-content-between align-items-center mb-3'>
|
||||||
<h2 class='h4 mb-0'>Audio Upload</h2>
|
<h2 class='h4 mb-0'>Audio Upload</h2>
|
||||||
</div>
|
</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 ""}
|
{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='card'>
|
||||||
<div class='row g-2'>
|
<div class='row g-3 mb-3'>
|
||||||
<div class='col-12 col-lg-4'>
|
<div class='col-12 col-md-5'>
|
||||||
<label class='form-label'>Projekt</label>
|
<label class='form-label'>Projekt <span class='text-secondary fw-normal'>(bestehendes wählen oder neuen Namen eingeben)</span></label>
|
||||||
<select class='form-select' name='project_id'>{opts}</select>
|
<input class='form-control' id='projectInput' list='projectList' placeholder='Projekt wählen oder anlegen …' autocomplete='off'>
|
||||||
|
<datalist id='projectList'></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class='col-12 col-lg-4'>
|
<div class='col-12 col-md-7'>
|
||||||
<label class='form-label'>Titel (optional)</label>
|
<label class='form-label'>Audio-Dateien</label>
|
||||||
<input class='form-control' name='title' placeholder='z. B. Team-Call 23.03'>
|
<input class='form-control' type='file' id='fileInput' accept='audio/*' multiple required>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='mt-3'>
|
<div id='filePreview' class='mb-3 d-none'>
|
||||||
<button class='btn btn-primary' type='submit'>Verarbeiten & speichern</button>
|
<div class='text-secondary small mb-1'>Warteschlange:</div>
|
||||||
|
<ul id='fileList' class='list-group list-group-flush'></ul>
|
||||||
</div>
|
</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)
|
return layout("Upload", body)
|
||||||
|
|
||||||
@@ -530,24 +600,38 @@ def delete_project(project_id: int):
|
|||||||
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
|
return HTMLResponse("<meta http-equiv='refresh' content='0; url=/prompts'>")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload", response_class=HTMLResponse)
|
@app.post("/projects/create-api")
|
||||||
async def upload(project_id: int = Form(...), title: str = Form(""), file: UploadFile = File(...)):
|
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()
|
data = await file.read()
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(400, "Leere Datei")
|
raise HTTPException(400, "Leere Datei")
|
||||||
|
|
||||||
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
JOB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
filename = (file.filename or "audio.bin").replace("/", "_")
|
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 = JOB_DIR / f"{now_iso().replace(':','-')}_{filename}"
|
||||||
temp_path.write_bytes(data)
|
temp_path.write_bytes(data)
|
||||||
|
|
||||||
job_id = enqueue_job(
|
job_id = enqueue_job(
|
||||||
"upload",
|
"upload",
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
title=(title or "").strip() or filename,
|
title=stem,
|
||||||
file_path=str(temp_path),
|
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)
|
@app.get("/library", response_class=HTMLResponse)
|
||||||
|
|||||||
Reference in New Issue
Block a user