Initial commit
This commit is contained in:
189
backend/routes/history.py
Normal file
189
backend/routes/history.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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)},
|
||||
)
|
||||
Reference in New Issue
Block a user