Files
NotesAgentic/backend/app/media_routes.py
T

199 lines
9.9 KiB
Python

"""Media storage and durable transcription controls."""
from __future__ import annotations
import asyncio
import json
import hashlib
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"])
from app.providers.routing import MAX_LOCAL_MEDIA_BYTES
MAX_UPLOAD_BYTES = MAX_LOCAL_MEDIA_BYTES
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),
idempotency_key: str | None = Header(None, min_length=16, max_length=100, pattern=r"^[a-zA-Z0-9_-]+$")):
suffix = Path(filename).suffix.lower()
if suffix not in MEDIA_SUFFIXES:
raise ApiError(422, "UNSUPPORTED_MEDIA", "Unsupported attachment extension.")
identity = hashlib.sha256(idempotency_key.encode()).hexdigest() if idempotency_key else uuid4().hex
attachment_id = f"media_{identity}{suffix}"
destination = attachment_path(attachment_id)
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + f".{uuid4().hex}.upload")
digest = hashlib.sha256()
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 128 MiB.")
digest.update(chunk)
stream.write(chunk)
if not size:
raise ApiError(422, "EMPTY_ATTACHMENT", "Attachment is empty.")
content_hash = digest.hexdigest()
if idempotency_key:
with closing(connect()) as conn:
conn.execute("CREATE TABLE IF NOT EXISTS media_upload_idempotency (idempotency_key TEXT PRIMARY KEY, attachment_id TEXT NOT NULL, filename TEXT NOT NULL, content_hash TEXT NOT NULL)")
conn.execute("BEGIN IMMEDIATE")
try:
row = conn.execute("SELECT attachment_id,filename,content_hash FROM media_upload_idempotency WHERE idempotency_key=?", (idempotency_key,)).fetchone()
if row:
if row["filename"] != Path(filename).name or row["content_hash"] != content_hash:
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
existing = attachment_path(row["attachment_id"])
if not existing.is_file() or hashlib.sha256(existing.read_bytes()).hexdigest() != content_hash:
raise ApiError(409, "IDEMPOTENCY_EXPIRED", "该上传标识对应的附件已不存在,请开始一次新提交。")
attachment_id = row["attachment_id"]
else:
if destination.exists() and hashlib.sha256(destination.read_bytes()).hexdigest() != content_hash:
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
if not destination.exists():
temporary.replace(destination)
conn.execute("INSERT INTO media_upload_idempotency VALUES (?,?,?,?)",
(idempotency_key, attachment_id, Path(filename).name, content_hash))
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
elif destination.exists():
if hashlib.sha256(destination.read_bytes()).digest() != digest.digest():
raise ApiError(409, "IDEMPOTENCY_CONFLICT", "同一上传标识不能用于不同附件。")
else:
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