163 lines
7.3 KiB
Python
163 lines
7.3 KiB
Python
"""Media storage and durable transcription controls."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from contextlib import closing
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Header, Query, Request
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
|
|
from app.contracts import TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
|
|
from app.database.db import connect, transaction
|
|
from app.errors import ApiError
|
|
from app.services import transcription_service as jobs
|
|
from app.services.attachment_service import attachment_path
|
|
|
|
router = APIRouter(prefix="/api/media", tags=["Media"])
|
|
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
|
|
MEDIA_SUFFIXES = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".mp4", ".webm", ".txt", ".md"}
|
|
|
|
|
|
@router.post("/attachments", status_code=201)
|
|
async def upload_attachment(request: Request, filename: str = Query(min_length=1, max_length=255)):
|
|
suffix = Path(filename).suffix.lower()
|
|
if suffix not in MEDIA_SUFFIXES:
|
|
raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.")
|
|
attachment_id = f"media_{uuid4().hex}{suffix}"
|
|
destination = attachment_path(attachment_id)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = destination.with_suffix(destination.suffix + ".upload")
|
|
size = 0
|
|
try:
|
|
with temporary.open("xb") as stream:
|
|
async for chunk in request.stream():
|
|
size += len(chunk)
|
|
if size > MAX_UPLOAD_BYTES:
|
|
raise ApiError(413, "ATTACHMENT_TOO_LARGE", "Attachment exceeds 25 MiB.")
|
|
stream.write(chunk)
|
|
if not size:
|
|
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
|
|
temporary.replace(destination)
|
|
finally:
|
|
temporary.unlink(missing_ok=True)
|
|
return {"attachment_id": attachment_id, "filename": Path(filename).name, "size": size}
|
|
|
|
|
|
@router.get("/attachments/{attachment_id}")
|
|
async def download_attachment(attachment_id: str):
|
|
path = attachment_path(attachment_id)
|
|
if not path.is_file():
|
|
raise ApiError(404, "ATTACHMENT_NOT_FOUND", "Attachment was not found.")
|
|
return FileResponse(path, headers={"X-Content-Type-Options": "nosniff"})
|
|
|
|
|
|
@router.get("/transcriptions")
|
|
async def list_jobs(status: str | None = None, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)):
|
|
if status is not None and status not in jobs.TERMINAL | {"queued", "running", "processing"}:
|
|
raise ApiError(422, "INVALID_STATUS", "Unknown transcription status.")
|
|
return jobs.list_transcriptions(status, limit, offset)
|
|
|
|
|
|
@router.post("/transcriptions/{job_id}/cancel", response_model=TranscriptionJob)
|
|
async def cancel_job(job_id: str):
|
|
return await jobs.cancel(job_id)
|
|
|
|
|
|
@router.post("/transcriptions/{job_id}/retry", response_model=TranscriptionJob, status_code=202)
|
|
async def retry_job(job_id: str):
|
|
return await jobs.retry(job_id)
|
|
|
|
|
|
@router.patch("/transcriptions/{job_id}", response_model=TranscriptionJob)
|
|
async def edit_job(job_id: str, request: TranscriptEditRequest):
|
|
return jobs.edit(job_id, request)
|
|
|
|
|
|
@router.get("/transcriptions/{job_id}/revisions")
|
|
async def revisions(job_id: str):
|
|
current = jobs.require_job(job_id)
|
|
with closing(connect()) as conn:
|
|
rows = conn.execute("SELECT job_json FROM media_revisions WHERE job_id=? ORDER BY revision", (job_id,)).fetchall()
|
|
return {"items": [TranscriptionJob.model_validate_json(row[0]) for row in rows] + [current]}
|
|
|
|
|
|
@router.get("/transcriptions/{job_id}/events")
|
|
async def stream_events(job_id: str, request: Request, after: int = Query(-1, ge=-1),
|
|
last_event_id: str | None = Header(None)):
|
|
jobs.require_job(job_id)
|
|
if last_event_id is not None:
|
|
try:
|
|
after = max(after, int(last_event_id))
|
|
except ValueError as exc:
|
|
raise ApiError(422, "INVALID_EVENT_CURSOR", "Last-Event-ID must be an integer.") from exc
|
|
|
|
async def stream():
|
|
cursor = after
|
|
idle = 0
|
|
while not await request.is_disconnected():
|
|
batch = jobs.events(job_id, cursor)
|
|
for event in batch:
|
|
cursor = event["sequence"]
|
|
yield f"id: {cursor}\nevent: {event['event']}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"
|
|
if len(batch) == 200:
|
|
continue
|
|
if jobs.require_job(job_id).status in jobs.TERMINAL:
|
|
# Re-read once: completion may have been committed after this batch was read.
|
|
if jobs.events(job_id, cursor):
|
|
continue
|
|
return
|
|
idle += 1
|
|
if idle % 30 == 0:
|
|
yield ": keepalive\n\n"
|
|
await asyncio.sleep(0.5)
|
|
|
|
return StreamingResponse(stream(), media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
|
|
|
|
@router.post("/transcriptions/{job_id}/notes", status_code=201)
|
|
async def create_note(job_id: str, request: TranscriptNoteRequest):
|
|
from app.services.media_notes import create_transcript_note
|
|
return await create_transcript_note(job_id, request)
|
|
|
|
|
|
@router.get("/attachments/{attachment_id}/cleanup-impact")
|
|
async def cleanup_impact(attachment_id: str):
|
|
attachment_path(attachment_id)
|
|
with closing(connect()) as conn:
|
|
records = conn.execute("SELECT job_json FROM media_jobs").fetchall()
|
|
affected = [TranscriptionJob.model_validate_json(row[0]) for row in records]
|
|
affected = [job for job in affected if job.attachment_id == attachment_id]
|
|
note_ids = []
|
|
for job in affected:
|
|
note_ids.extend(row[0] for row in conn.execute("SELECT note_id FROM media_notes WHERE job_id=?", (job.job_id,)))
|
|
return {"job_ids": [job.job_id for job in affected], "retained_note_ids": sorted(set(note_ids)),
|
|
"message": "清理原附件、转写正文、修订和术语记录;已保存笔记保留,音频链接将失效。"}
|
|
|
|
|
|
@router.delete("/attachments/{attachment_id}")
|
|
async def cleanup_attachment(attachment_id: str):
|
|
from app.local_models.runtime import runtime
|
|
impact = await cleanup_impact(attachment_id)
|
|
affected = [jobs.require_job(job_id) for job_id in impact["job_ids"]]
|
|
if runtime.media_in_use(attachment_path(attachment_id)) or any(job.status not in jobs.TERMINAL for job in affected):
|
|
raise ApiError(409, "MEDIA_IN_USE", "Wait for media processing to finish before cleanup.")
|
|
for path in (attachment_path(attachment_id), attachment_path(f"{attachment_id}.txt")):
|
|
path.unlink(missing_ok=True)
|
|
with closing(connect()) as conn, transaction(conn):
|
|
for job in affected:
|
|
job.text = job.original_text = None
|
|
job.segments = []; job.original_segments = []; job.speaker_names = {}; job.corrections = []
|
|
job.model_snapshot = {}
|
|
job.status = "cancelled"; job.error_code = "MEDIA_PURGED"; job.error_message = "附件与转写内容已清理。"
|
|
job.updated_at = jobs.now()
|
|
conn.execute("UPDATE media_jobs SET job_json=?,status=?,request_json='{}' WHERE job_id=?",
|
|
(job.model_dump_json(), job.status, job.job_id))
|
|
conn.execute("DELETE FROM media_revisions WHERE job_id=?", (job.job_id,))
|
|
conn.execute("DELETE FROM media_events WHERE job_id=?", (job.job_id,))
|
|
jobs._event(conn, job, "Purged")
|
|
return impact
|