190 lines
6.0 KiB
Python
190 lines
6.0 KiB
Python
"""Generation history endpoints."""
|
|
|
|
import io
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import config, models
|
|
from ..services import export_import, history
|
|
from ..app import safe_content_disposition
|
|
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/history", response_model=models.HistoryListResponse)
|
|
async def list_history(
|
|
profile_id: str | None = None,
|
|
search: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List generation history with optional filters."""
|
|
query = models.HistoryQuery(
|
|
profile_id=profile_id,
|
|
search=search,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
return await history.list_generations(query, db)
|
|
|
|
|
|
@router.get("/history/stats")
|
|
async def get_stats(db: Session = Depends(get_db)):
|
|
"""Get generation statistics."""
|
|
return await history.get_generation_stats(db)
|
|
|
|
|
|
@router.post("/history/import")
|
|
async def import_generation(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Import a generation from a ZIP archive."""
|
|
MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
|
|
content = await file.read()
|
|
|
|
if len(content) > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
|
|
)
|
|
|
|
try:
|
|
result = await export_import.import_generation_from_zip(content, db)
|
|
return result
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.delete("/history/failed")
|
|
async def clear_failed_generations(db: Session = Depends(get_db)):
|
|
"""Delete every generation with status='failed'. Used by the UI's 'Clear failed' button (#410)."""
|
|
count = await history.delete_failed_generations(db)
|
|
return {"deleted": count}
|
|
|
|
|
|
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
|
|
async def get_generation(
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a generation by ID."""
|
|
result = (
|
|
db.query(DBGeneration, DBVoiceProfile.name.label("profile_name"))
|
|
.join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
|
|
.filter(DBGeneration.id == generation_id)
|
|
.first()
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail="Generation not found")
|
|
|
|
gen, profile_name = result
|
|
return models.HistoryResponse(
|
|
id=gen.id,
|
|
profile_id=gen.profile_id,
|
|
profile_name=profile_name,
|
|
text=gen.text,
|
|
language=gen.language,
|
|
audio_path=gen.audio_path,
|
|
duration=gen.duration,
|
|
seed=gen.seed,
|
|
instruct=gen.instruct,
|
|
engine=gen.engine or "qwen",
|
|
model_size=gen.model_size,
|
|
status=gen.status or "completed",
|
|
error=gen.error,
|
|
is_favorited=bool(gen.is_favorited),
|
|
created_at=gen.created_at,
|
|
)
|
|
|
|
|
|
@router.post("/history/{generation_id}/favorite")
|
|
async def toggle_favorite(
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Toggle the favorite status of a generation."""
|
|
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
|
if not gen:
|
|
raise HTTPException(status_code=404, detail="Generation not found")
|
|
gen.is_favorited = not gen.is_favorited
|
|
db.commit()
|
|
return {"is_favorited": gen.is_favorited}
|
|
|
|
|
|
@router.delete("/history/{generation_id}")
|
|
async def delete_generation(
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Delete a generation."""
|
|
success = await history.delete_generation(generation_id, db)
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Generation not found")
|
|
return {"message": "Generation deleted successfully"}
|
|
|
|
|
|
@router.get("/history/{generation_id}/export")
|
|
async def export_generation(
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Export a generation as a ZIP archive."""
|
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
|
if not generation:
|
|
raise HTTPException(status_code=404, detail="Generation not found")
|
|
|
|
try:
|
|
zip_bytes = export_import.export_generation_to_zip(generation_id, db)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
|
if not safe_text:
|
|
safe_text = "generation"
|
|
filename = f"generation-{safe_text}.voicebox.zip"
|
|
|
|
return StreamingResponse(
|
|
io.BytesIO(zip_bytes),
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
|
|
)
|
|
|
|
|
|
@router.get("/history/{generation_id}/export-audio")
|
|
async def export_generation_audio(
|
|
generation_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Export only the audio file from a generation."""
|
|
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
|
|
if not generation:
|
|
raise HTTPException(status_code=404, detail="Generation not found")
|
|
|
|
if not generation.audio_path:
|
|
raise HTTPException(status_code=404, detail="Generation has no audio file")
|
|
|
|
audio_path = config.resolve_storage_path(generation.audio_path)
|
|
if audio_path is None or not audio_path.is_file():
|
|
raise HTTPException(status_code=404, detail="Audio file not found")
|
|
|
|
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
|
if not safe_text:
|
|
safe_text = "generation"
|
|
filename = f"{safe_text}.wav"
|
|
|
|
return FileResponse(
|
|
audio_path,
|
|
media_type="audio/wav",
|
|
headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
|
|
)
|