mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-03-03 02:27:01 +00:00
2809 lines
89 KiB
Go
2809 lines
89 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"scriberr/internal/auth"
|
|
"scriberr/internal/config"
|
|
"scriberr/internal/database"
|
|
"scriberr/internal/models"
|
|
"scriberr/internal/processing"
|
|
"scriberr/internal/queue"
|
|
"scriberr/internal/repository"
|
|
"scriberr/internal/service"
|
|
"scriberr/internal/transcription"
|
|
"scriberr/pkg/logger"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Handler contains all the API handlers
|
|
type Handler struct {
|
|
config *config.Config
|
|
authService *auth.AuthService
|
|
userService service.UserService
|
|
fileService service.FileService
|
|
jobRepo repository.JobRepository
|
|
apiKeyRepo repository.APIKeyRepository
|
|
profileRepo repository.ProfileRepository
|
|
userRepo repository.UserRepository
|
|
llmConfigRepo repository.LLMConfigRepository
|
|
summaryRepo repository.SummaryRepository
|
|
chatRepo repository.ChatRepository
|
|
noteRepo repository.NoteRepository
|
|
speakerMappingRepo repository.SpeakerMappingRepository
|
|
taskQueue *queue.TaskQueue
|
|
unifiedProcessor *transcription.UnifiedJobProcessor
|
|
quickTranscription *transcription.QuickTranscriptionService
|
|
multiTrackProcessor *processing.MultiTrackProcessor
|
|
}
|
|
|
|
// NewHandler creates a new handler
|
|
func NewHandler(
|
|
cfg *config.Config,
|
|
authService *auth.AuthService,
|
|
userService service.UserService,
|
|
fileService service.FileService,
|
|
jobRepo repository.JobRepository,
|
|
apiKeyRepo repository.APIKeyRepository,
|
|
profileRepo repository.ProfileRepository,
|
|
userRepo repository.UserRepository,
|
|
llmConfigRepo repository.LLMConfigRepository,
|
|
summaryRepo repository.SummaryRepository,
|
|
chatRepo repository.ChatRepository,
|
|
noteRepo repository.NoteRepository,
|
|
speakerMappingRepo repository.SpeakerMappingRepository,
|
|
taskQueue *queue.TaskQueue,
|
|
unifiedProcessor *transcription.UnifiedJobProcessor,
|
|
quickTranscription *transcription.QuickTranscriptionService,
|
|
) *Handler {
|
|
return &Handler{
|
|
config: cfg,
|
|
authService: authService,
|
|
userService: userService,
|
|
fileService: fileService,
|
|
jobRepo: jobRepo,
|
|
apiKeyRepo: apiKeyRepo,
|
|
profileRepo: profileRepo,
|
|
userRepo: userRepo,
|
|
llmConfigRepo: llmConfigRepo,
|
|
summaryRepo: summaryRepo,
|
|
chatRepo: chatRepo,
|
|
noteRepo: noteRepo,
|
|
speakerMappingRepo: speakerMappingRepo,
|
|
taskQueue: taskQueue,
|
|
unifiedProcessor: unifiedProcessor,
|
|
quickTranscription: quickTranscription,
|
|
multiTrackProcessor: processing.NewMultiTrackProcessor(),
|
|
}
|
|
}
|
|
|
|
// SubmitJobRequest represents the submit job request
|
|
type SubmitJobRequest struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Diarization bool `json:"diarization"`
|
|
Parameters models.WhisperXParams `json:"parameters"`
|
|
}
|
|
|
|
// LoginRequest represents the login request
|
|
type LoginRequest struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// LoginResponse represents the login response
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
User struct {
|
|
ID uint `json:"id"`
|
|
Username string `json:"username"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
// RegisterRequest represents the registration request
|
|
type RegisterRequest struct {
|
|
Username string `json:"username" binding:"required,min=3,max=50"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
ConfirmPassword string `json:"confirmPassword" binding:"required"`
|
|
}
|
|
|
|
// RegistrationStatusResponse represents the registration status
|
|
type RegistrationStatusResponse struct {
|
|
// Match tests expecting snake_case key
|
|
RegistrationEnabled bool `json:"registration_enabled"`
|
|
}
|
|
|
|
// ChangePasswordRequest represents the change password request
|
|
type ChangePasswordRequest struct {
|
|
CurrentPassword string `json:"currentPassword" binding:"required"`
|
|
NewPassword string `json:"newPassword" binding:"required,min=6"`
|
|
ConfirmPassword string `json:"confirmPassword" binding:"required"`
|
|
}
|
|
|
|
// ChangeUsernameRequest represents the change username request
|
|
type ChangeUsernameRequest struct {
|
|
NewUsername string `json:"newUsername" binding:"required,min=3,max=50"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
// CreateAPIKeyRequest represents the create API key request
|
|
type CreateAPIKeyRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=100"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// CreateAPIKeyResponse represents the create API key response
|
|
type CreateAPIKeyResponse struct {
|
|
ID uint `json:"id"`
|
|
Key string `json:"key"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// YouTubeDownloadRequest represents the YouTube download request
|
|
type YouTubeDownloadRequest struct {
|
|
URL string `json:"url" binding:"required"`
|
|
Title *string `json:"title,omitempty"`
|
|
}
|
|
|
|
// YouTubeDownloadResponse represents the YouTube download response
|
|
type YouTubeDownloadResponse struct {
|
|
JobID string `json:"job_id"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
Progress int `json:"progress,omitempty"`
|
|
}
|
|
|
|
// LLMConfigRequest represents the LLM configuration request
|
|
type LLMConfigRequest struct {
|
|
Provider string `json:"provider" binding:"required,oneof=ollama openai"`
|
|
BaseURL *string `json:"base_url,omitempty"`
|
|
OpenAIBaseURL *string `json:"openai_base_url,omitempty"`
|
|
APIKey *string `json:"api_key,omitempty"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
|
|
// LLMConfigResponse represents the LLM configuration response
|
|
type LLMConfigResponse struct {
|
|
ID uint `json:"id"`
|
|
Provider string `json:"provider"`
|
|
BaseURL *string `json:"base_url,omitempty"`
|
|
OpenAIBaseURL *string `json:"openai_base_url,omitempty"`
|
|
HasAPIKey bool `json:"has_api_key"` // Don't return actual API key
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// APIKeyListResponse represents an API key in the list (without the actual key)
|
|
type APIKeyListResponse struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
KeyPreview string `json:"key_preview"`
|
|
IsActive bool `json:"is_active"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
LastUsed string `json:"last_used,omitempty"`
|
|
}
|
|
|
|
// APIKeysWrapper wraps the API keys list response
|
|
type APIKeysWrapper struct {
|
|
APIKeys []APIKeyListResponse `json:"api_keys"`
|
|
}
|
|
|
|
// transformAPIKeyForList converts a models.APIKey to APIKeyListResponse
|
|
func transformAPIKeyForList(apiKey models.APIKey) APIKeyListResponse {
|
|
keyPreview := ""
|
|
if len(apiKey.Key) > 8 {
|
|
keyPreview = apiKey.Key[:8] + "..."
|
|
} else if apiKey.Key != "" {
|
|
keyPreview = apiKey.Key + "..."
|
|
}
|
|
|
|
lastUsed := ""
|
|
if apiKey.LastUsed != nil {
|
|
lastUsed = apiKey.LastUsed.Format(time.RFC3339)
|
|
}
|
|
|
|
description := ""
|
|
if apiKey.Description != nil {
|
|
description = *apiKey.Description
|
|
}
|
|
|
|
return APIKeyListResponse{
|
|
ID: apiKey.ID,
|
|
Name: apiKey.Name,
|
|
Description: description,
|
|
KeyPreview: keyPreview,
|
|
IsActive: apiKey.IsActive,
|
|
CreatedAt: apiKey.CreatedAt.Format(time.RFC3339),
|
|
UpdatedAt: apiKey.UpdatedAt.Format(time.RFC3339),
|
|
LastUsed: lastUsed,
|
|
}
|
|
}
|
|
|
|
// @Summary Upload audio file
|
|
// @Description Upload an audio file without starting transcription
|
|
// @Tags transcription
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param audio formData file true "Audio file"
|
|
// @Param title formData string false "Job title"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/upload [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) UploadAudio(c *gin.Context) {
|
|
// Note: This endpoint is also used by the CLI watcher to upload files.
|
|
// The CLI authenticates using a long-lived JWT token.
|
|
|
|
// Parse multipart form
|
|
header, err := c.FormFile("audio")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Audio file is required"})
|
|
return
|
|
}
|
|
|
|
// Save file using FileService
|
|
uploadDir := h.config.UploadDir
|
|
filePath, err := h.fileService.SaveUpload(header, uploadDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
|
return
|
|
}
|
|
|
|
// Create job record
|
|
jobID := filepath.Base(filePath)
|
|
jobID = jobID[:len(jobID)-len(filepath.Ext(jobID))] // Extract ID from filename
|
|
|
|
job := models.TranscriptionJob{
|
|
ID: jobID,
|
|
AudioPath: filePath,
|
|
Status: models.StatusUploaded,
|
|
}
|
|
|
|
if title := c.PostForm("title"); title != "" {
|
|
job.Title = &title
|
|
}
|
|
|
|
// Save to database using Repository
|
|
if err := h.jobRepo.Create(c.Request.Context(), &job); err != nil {
|
|
h.fileService.RemoveFile(filePath) // Clean up file
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
|
|
return
|
|
}
|
|
|
|
// Check for auto-transcription if user is authenticated via JWT
|
|
if userID, exists := c.Get("user_id"); exists {
|
|
// Use UserService to get user
|
|
user, err := h.userService.GetUser(c.Request.Context(), userID.(uint))
|
|
if err == nil && user.AutoTranscriptionEnabled {
|
|
// Get user's default profile or use system default
|
|
var profile *models.TranscriptionProfile
|
|
|
|
if user.DefaultProfileID != nil {
|
|
profile, _ = h.profileRepo.FindByID(c.Request.Context(), *user.DefaultProfileID)
|
|
}
|
|
|
|
// If no user default or user default not found, try to find a system default
|
|
if profile == nil {
|
|
profile, _ = h.profileRepo.FindDefault(c.Request.Context())
|
|
}
|
|
|
|
// If still no profile found, use the first available profile
|
|
if profile == nil {
|
|
profiles, _, _ := h.profileRepo.List(c.Request.Context(), 0, 1)
|
|
if len(profiles) > 0 {
|
|
profile = &profiles[0]
|
|
}
|
|
}
|
|
|
|
// If we found a profile, update the job and queue it
|
|
if profile != nil {
|
|
job.Parameters = profile.Parameters
|
|
job.Diarization = profile.Parameters.Diarize
|
|
job.Status = models.StatusPending
|
|
|
|
// Update the job in database
|
|
if err := h.jobRepo.Update(c.Request.Context(), &job); err == nil {
|
|
// Enqueue the job for transcription
|
|
if err := h.taskQueue.EnqueueJob(jobID); err != nil {
|
|
// If enqueueing fails, revert status but don't fail the upload
|
|
job.Status = models.StatusUploaded
|
|
h.jobRepo.Update(c.Request.Context(), &job)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Upload video file for transcription
|
|
// @Description Upload a video file, extract audio from it using ffmpeg, and create a transcription job
|
|
// @Tags transcription
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param video formData file true "Video file"
|
|
// @Param title formData string false "Job title"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/upload-video [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) UploadVideo(c *gin.Context) {
|
|
// Parse multipart form
|
|
header, err := c.FormFile("video")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Video file is required"})
|
|
return
|
|
}
|
|
|
|
// Save file using FileService
|
|
uploadDir := h.config.UploadDir
|
|
videoPath, err := h.fileService.SaveUpload(header, uploadDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
|
return
|
|
}
|
|
|
|
// Generate job ID from filename
|
|
jobID := filepath.Base(videoPath)
|
|
jobID = jobID[:len(jobID)-len(filepath.Ext(jobID))]
|
|
|
|
// Extract audio using ffmpeg (keep this logic here for now, or move to a MediaService)
|
|
audioPath := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + ".mp3"
|
|
cmd := exec.Command("ffmpeg", "-i", videoPath, "-vn", "-acodec", "libmp3lame", "-q:a", "2", audioPath)
|
|
if err := cmd.Run(); err != nil {
|
|
h.fileService.RemoveFile(videoPath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to extract audio from video"})
|
|
return
|
|
}
|
|
|
|
// Create job record
|
|
job := models.TranscriptionJob{
|
|
ID: jobID,
|
|
AudioPath: audioPath, // Use the extracted audio path
|
|
Status: models.StatusUploaded,
|
|
}
|
|
|
|
if title := c.PostForm("title"); title != "" {
|
|
job.Title = &title
|
|
}
|
|
|
|
// Save to database
|
|
if err := h.jobRepo.Create(c.Request.Context(), &job); err != nil {
|
|
h.fileService.RemoveFile(videoPath)
|
|
h.fileService.RemoveFile(audioPath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
|
|
return
|
|
}
|
|
|
|
// Clean up video file as we only need audio
|
|
// TODO: Make this configurable? Some users might want to keep the video.
|
|
h.fileService.RemoveFile(videoPath)
|
|
|
|
// Check for auto-transcription (same logic as UploadAudio)
|
|
if userID, exists := c.Get("user_id"); exists {
|
|
user, err := h.userService.GetUser(c.Request.Context(), userID.(uint))
|
|
if err == nil && user.AutoTranscriptionEnabled {
|
|
var profile *models.TranscriptionProfile
|
|
if user.DefaultProfileID != nil {
|
|
profile, _ = h.profileRepo.FindByID(c.Request.Context(), *user.DefaultProfileID)
|
|
}
|
|
if profile == nil {
|
|
profile, _ = h.profileRepo.FindDefault(c.Request.Context())
|
|
}
|
|
if profile == nil {
|
|
profiles, _, _ := h.profileRepo.List(c.Request.Context(), 0, 1)
|
|
if len(profiles) > 0 {
|
|
profile = &profiles[0]
|
|
}
|
|
}
|
|
|
|
if profile != nil {
|
|
job.Parameters = profile.Parameters
|
|
job.Diarization = profile.Parameters.Diarize
|
|
job.Status = models.StatusPending
|
|
if err := h.jobRepo.Update(c.Request.Context(), &job); err == nil {
|
|
if err := h.taskQueue.EnqueueJob(jobID); err != nil {
|
|
job.Status = models.StatusUploaded
|
|
h.jobRepo.Update(c.Request.Context(), &job)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Upload multi-track audio files
|
|
// @Description Upload multiple audio files for multi-track transcription
|
|
// @Tags transcription
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param title formData string false "Job title"
|
|
// @Param files formData file true "Audio track files" multiple
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/upload-multitrack [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) UploadMultiTrack(c *gin.Context) {
|
|
// Parse multipart form
|
|
form, err := c.MultipartForm()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse multipart form"})
|
|
return
|
|
}
|
|
|
|
files := form.File["files"]
|
|
if len(files) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "No files uploaded"})
|
|
return
|
|
}
|
|
|
|
// Create a unique job ID
|
|
jobID := uuid.New().String()
|
|
uploadDir := h.config.UploadDir
|
|
|
|
// Create job directory
|
|
jobDir := filepath.Join(uploadDir, jobID)
|
|
if err := h.fileService.CreateDirectory(jobDir); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job directory"})
|
|
return
|
|
}
|
|
|
|
var trackFiles []models.MultiTrackFile
|
|
|
|
// Process each file
|
|
for i, fileHeader := range files {
|
|
// Save file using FileService
|
|
filePath, err := h.fileService.SaveUpload(fileHeader, jobDir)
|
|
if err != nil {
|
|
// Cleanup
|
|
h.fileService.RemoveDirectory(jobDir)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save file %s", fileHeader.Filename)})
|
|
return
|
|
}
|
|
|
|
// Create track record
|
|
trackFiles = append(trackFiles, models.MultiTrackFile{
|
|
TranscriptionJobID: jobID,
|
|
FilePath: filePath,
|
|
FileName: fileHeader.Filename,
|
|
TrackIndex: i,
|
|
})
|
|
}
|
|
|
|
// Create job record
|
|
job := models.TranscriptionJob{
|
|
ID: jobID,
|
|
Status: models.StatusUploaded,
|
|
IsMultiTrack: true,
|
|
MultiTrackFiles: trackFiles,
|
|
}
|
|
|
|
if title := c.PostForm("title"); title != "" {
|
|
job.Title = &title
|
|
} else {
|
|
defaultTitle := fmt.Sprintf("Multi-track Job %s", jobID)
|
|
job.Title = &defaultTitle
|
|
}
|
|
|
|
// Save to database
|
|
if err := h.jobRepo.Create(c.Request.Context(), &job); err != nil {
|
|
h.fileService.RemoveDirectory(jobDir)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// @Summary Get multi-track merge status
|
|
// @Description Get the current merge status for a multi-track job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/merge-status [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetMergeStatus(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
status, errorMsg, err := h.multiTrackProcessor.GetMergeStatus(jobID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
|
|
response := gin.H{
|
|
"merge_status": status,
|
|
}
|
|
|
|
if errorMsg != nil {
|
|
response["merge_error"] = *errorMsg
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Get multi-track job progress
|
|
// @Description Get real-time progress information for individual tracks in a multi-track job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/track-progress [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetTrackProgress(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
// Get the main job details
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
|
|
// Only provide track progress for multi-track jobs
|
|
if !job.IsMultiTrack {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Not a multi-track job"})
|
|
return
|
|
}
|
|
|
|
// Get individual transcripts to see which tracks are completed
|
|
var individualTranscripts map[string]string
|
|
if job.IndividualTranscripts != nil {
|
|
if err := json.Unmarshal([]byte(*job.IndividualTranscripts), &individualTranscripts); err == nil {
|
|
// Successfully parsed individual transcripts
|
|
}
|
|
}
|
|
|
|
// Find active track jobs (temp jobs still in progress)
|
|
var activeTrackJobs []models.TranscriptionJob
|
|
database.DB.Where("id LIKE ? AND status IN (?)", "track_"+jobID+"_%", []string{"processing", "pending"}).Find(&activeTrackJobs)
|
|
|
|
// Build track progress information
|
|
trackProgress := make([]map[string]interface{}, 0)
|
|
totalTracks := len(job.MultiTrackFiles)
|
|
completedTracks := 0
|
|
|
|
for _, trackFile := range job.MultiTrackFiles {
|
|
trackInfo := map[string]interface{}{
|
|
"track_name": trackFile.FileName,
|
|
"track_index": trackFile.TrackIndex,
|
|
}
|
|
|
|
// Check if this track is completed (only count if actually in individualTranscripts)
|
|
if _, exists := individualTranscripts[trackFile.FileName]; exists {
|
|
trackInfo["status"] = "completed"
|
|
completedTracks++
|
|
} else {
|
|
// Check if there's an active job for this track
|
|
isActive := false
|
|
for _, activeJob := range activeTrackJobs {
|
|
if strings.Contains(activeJob.ID, trackFile.FileName) {
|
|
trackInfo["status"] = "processing"
|
|
isActive = true
|
|
break
|
|
}
|
|
}
|
|
if !isActive {
|
|
if job.Status == "processing" {
|
|
trackInfo["status"] = "pending"
|
|
} else {
|
|
trackInfo["status"] = "failed"
|
|
}
|
|
}
|
|
}
|
|
|
|
trackProgress = append(trackProgress, trackInfo)
|
|
}
|
|
|
|
// Calculate overall progress based on actual track status
|
|
progressPercentage := 0.0
|
|
if totalTracks > 0 {
|
|
progressPercentage = float64(completedTracks) / float64(totalTracks) * 100
|
|
}
|
|
|
|
response := gin.H{
|
|
"job_id": jobID,
|
|
"is_multi_track": true,
|
|
"overall_status": job.Status,
|
|
"merge_status": job.MergeStatus,
|
|
"tracks": trackProgress,
|
|
"progress": map[string]interface{}{
|
|
"completed_tracks": completedTracks,
|
|
"total_tracks": totalTracks,
|
|
"percentage": progressPercentage,
|
|
},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Submit a transcription job
|
|
// @Description Submit an audio file for transcription with WhisperX
|
|
// @Tags transcription
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param audio formData file true "Audio file"
|
|
// @Param title formData string false "Job title"
|
|
// @Param diarization formData boolean false "Enable speaker diarization"
|
|
// @Param model formData string false "Whisper model" default(base)
|
|
// @Param language formData string false "Language code"
|
|
// @Param batch_size formData int false "Batch size" default(16)
|
|
// @Param compute_type formData string false "Compute type" default(float16)
|
|
// @Param device formData string false "Device" default(auto)
|
|
// @Param vad_filter formData boolean false "Enable VAD filter"
|
|
// @Param vad_onset formData number false "VAD onset" default(0.500)
|
|
// @Param vad_offset formData number false "VAD offset" default(0.363)
|
|
// @Param min_speakers formData int false "Minimum speakers for diarization"
|
|
// @Param max_speakers formData int false "Maximum speakers for diarization"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/submit [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) SubmitJob(c *gin.Context) {
|
|
// Parse multipart form
|
|
header, err := c.FormFile("audio")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Audio file is required"})
|
|
return
|
|
}
|
|
|
|
// Save file using FileService
|
|
uploadDir := h.config.UploadDir
|
|
filePath, err := h.fileService.SaveUpload(header, uploadDir)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
|
return
|
|
}
|
|
|
|
// Generate job ID from filename
|
|
jobID := filepath.Base(filePath)
|
|
jobID = jobID[:len(jobID)-len(filepath.Ext(jobID))]
|
|
|
|
// Parse parameters (accept both 'diarization' and 'diarize')
|
|
diarize := false
|
|
if v := c.PostForm("diarization"); v != "" {
|
|
diarize = strings.EqualFold(v, "true") || v == "1"
|
|
} else {
|
|
diarize = getFormBoolWithDefault(c, "diarize", false)
|
|
}
|
|
params := models.WhisperXParams{
|
|
Model: getFormValueWithDefault(c, "model", "base"),
|
|
BatchSize: getFormIntWithDefault(c, "batch_size", 16),
|
|
ComputeType: getFormValueWithDefault(c, "compute_type", "int8"),
|
|
Device: getFormValueWithDefault(c, "device", "cpu"),
|
|
VadOnset: getFormFloatWithDefault(c, "vad_onset", 0.500),
|
|
VadOffset: getFormFloatWithDefault(c, "vad_offset", 0.363),
|
|
Diarize: diarize,
|
|
}
|
|
|
|
if lang := c.PostForm("language"); lang != "" {
|
|
params.Language = &lang
|
|
}
|
|
|
|
if minSpeakers := c.PostForm("min_speakers"); minSpeakers != "" {
|
|
if min, err := strconv.Atoi(minSpeakers); err == nil {
|
|
params.MinSpeakers = &min
|
|
}
|
|
}
|
|
|
|
if maxSpeakers := c.PostForm("max_speakers"); maxSpeakers != "" {
|
|
if max, err := strconv.Atoi(maxSpeakers); err == nil {
|
|
params.MaxSpeakers = &max
|
|
}
|
|
}
|
|
|
|
if hfToken := c.PostForm("hf_token"); hfToken != "" {
|
|
params.HfToken = &hfToken
|
|
}
|
|
|
|
// Parse and validate diarization model
|
|
diarizeModel := getFormValueWithDefault(c, "diarize_model", "pyannote")
|
|
if diarizeModel != "pyannote" && diarizeModel != "nvidia_sortformer" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid diarize_model. Must be 'pyannote' or 'nvidia_sortformer'"})
|
|
h.fileService.RemoveFile(filePath)
|
|
return
|
|
}
|
|
params.DiarizeModel = diarizeModel
|
|
|
|
// Create job
|
|
job := models.TranscriptionJob{
|
|
ID: jobID,
|
|
AudioPath: filePath,
|
|
Status: models.StatusPending,
|
|
Diarization: diarize,
|
|
Parameters: params,
|
|
}
|
|
|
|
if title := c.PostForm("title"); title != "" {
|
|
job.Title = &title
|
|
}
|
|
|
|
// Save to database
|
|
if err := h.jobRepo.Create(c.Request.Context(), &job); err != nil {
|
|
h.fileService.RemoveFile(filePath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create job"})
|
|
return
|
|
}
|
|
|
|
// Enqueue job
|
|
if err := h.taskQueue.EnqueueJob(jobID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue job"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Get job status
|
|
// @Description Get the current status of a transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/status [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetJobStatus(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
job, err := h.taskQueue.GetJobStatus(jobID)
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Get transcript
|
|
// @Description Get the transcript for a completed transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/transcript [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetTranscript(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
|
|
return
|
|
}
|
|
|
|
if job.Status != models.StatusCompleted {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": fmt.Sprintf("Job not completed, current status: %s", job.Status),
|
|
})
|
|
return
|
|
}
|
|
|
|
if job.Transcript == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Transcript not available"})
|
|
return
|
|
}
|
|
|
|
var transcript interface{}
|
|
if err := json.Unmarshal([]byte(*job.Transcript), &transcript); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse transcript"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"job_id": job.ID,
|
|
"title": job.Title,
|
|
"transcript": transcript,
|
|
"created_at": job.CreatedAt,
|
|
"updated_at": job.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
// @Summary List all transcription records
|
|
// @Description Get a list of all transcription jobs with optional search and filtering
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param limit query int false "Items per page" default(10)
|
|
// @Summary List all transcription records
|
|
// @Description Get a list of all transcription jobs with optional search and filtering
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param limit query int false "Items per page" default(10)
|
|
// @Param sort_by query string false "Sort By"
|
|
// @Param sort_order query string false "Sort Order (asc/desc)"
|
|
// @Param status query string false "Filter by status"
|
|
// @Param q query string false "Search in title and audio filename"
|
|
// @Param updated_after query string false "Filter by updated_at > timestamp (RFC3339)"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/list [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) ListTranscriptionJobs(c *gin.Context) {
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
|
offset := (page - 1) * limit
|
|
|
|
sortBy := c.Query("sort_by")
|
|
sortOrder := c.Query("sort_order")
|
|
searchQuery := c.Query("q")
|
|
updatedAfterStr := c.Query("updated_after")
|
|
|
|
var updatedAfter *time.Time
|
|
if updatedAfterStr != "" {
|
|
if t, err := time.Parse(time.RFC3339, updatedAfterStr); err == nil {
|
|
updatedAfter = &t
|
|
} else {
|
|
// Try other formats or log error? For now, ignore invalid dates or return error?
|
|
// Spec says RFC3339.
|
|
// Let's just log it and ignore, or ignore.
|
|
// Better: strict parsing, maybe return 400?
|
|
// User request: "Check for Param: Parse updated_after... "
|
|
// "If provided... filters results"
|
|
// I'll stick to strict parsing if possible, but let's just proceed with standard behavior if parse fails or maybe strictly fallback?
|
|
// Given it's a sync API, maybe best to respect valid only.
|
|
}
|
|
}
|
|
|
|
jobs, total, err := h.jobRepo.ListWithParams(c.Request.Context(), offset, limit, sortBy, sortOrder, searchQuery, updatedAfter)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list jobs"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"jobs": jobs,
|
|
"pagination": gin.H{
|
|
"page": page,
|
|
"limit": limit,
|
|
"total": total,
|
|
"pages": (total + int64(limit) - 1) / int64(limit),
|
|
},
|
|
})
|
|
}
|
|
|
|
// @Summary Get transcription job details
|
|
// @Description Get details of a specific transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id} [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetTranscriptionJob(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
job, err := h.jobRepo.FindWithAssociations(c.Request.Context(), id)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Start transcription for uploaded file
|
|
// @Description Start transcription for an already uploaded audio file
|
|
// @Tags transcription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Param parameters body models.WhisperXParams true "Transcription parameters"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/start [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) StartTranscription(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
|
|
return
|
|
}
|
|
|
|
// Allow transcription for uploaded, completed, and failed jobs (re-transcription)
|
|
if job.Status != models.StatusUploaded && job.Status != models.StatusCompleted && job.Status != models.StatusFailed {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot start transcription: job is currently processing or pending"})
|
|
return
|
|
}
|
|
|
|
// Parse transcription parameters from request body
|
|
var requestParams models.WhisperXParams
|
|
|
|
// Set defaults
|
|
requestParams = models.WhisperXParams{
|
|
ModelFamily: "whisper", // Default to whisper for backward compatibility
|
|
Model: "small",
|
|
ModelCacheOnly: false,
|
|
Device: "cpu",
|
|
DeviceIndex: 0,
|
|
BatchSize: 8,
|
|
ComputeType: "float32",
|
|
Threads: 0,
|
|
OutputFormat: "all",
|
|
Verbose: true,
|
|
Task: "transcribe",
|
|
InterpolateMethod: "nearest",
|
|
NoAlign: false,
|
|
ReturnCharAlignments: false,
|
|
VadMethod: "pyannote",
|
|
VadOnset: 0.5,
|
|
VadOffset: 0.363,
|
|
ChunkSize: 30,
|
|
Diarize: false,
|
|
DiarizeModel: "pyannote/speaker-diarization-3.1",
|
|
SpeakerEmbeddings: false,
|
|
Temperature: 0,
|
|
BestOf: 5,
|
|
BeamSize: 5,
|
|
Patience: 1.0,
|
|
LengthPenalty: 1.0,
|
|
SuppressNumerals: false,
|
|
ConditionOnPreviousText: false,
|
|
Fp16: true,
|
|
TemperatureIncrementOnFallback: 0.2,
|
|
CompressionRatioThreshold: 2.4,
|
|
LogprobThreshold: -1.0,
|
|
NoSpeechThreshold: 0.6,
|
|
HighlightWords: false,
|
|
SegmentResolution: "sentence",
|
|
PrintProgress: false,
|
|
AttentionContextLeft: 256,
|
|
AttentionContextRight: 256,
|
|
IsMultiTrackEnabled: false,
|
|
}
|
|
|
|
// Parse request body parameters, overriding defaults
|
|
if err := c.ShouldBindJSON(&requestParams); err != nil {
|
|
// Use defaults if JSON parsing fails
|
|
logger.Debug("Failed to parse JSON parameters, using defaults", "error", err)
|
|
}
|
|
|
|
// Debug: log what we received
|
|
logger.Debug("Parsed transcription parameters",
|
|
"job_id", jobID,
|
|
"model_family", requestParams.ModelFamily,
|
|
"model", requestParams.Model,
|
|
"diarization", requestParams.Diarize,
|
|
"diarize_model", requestParams.DiarizeModel,
|
|
"language", requestParams.Language)
|
|
|
|
// Validate NVIDIA-specific constraints
|
|
if requestParams.ModelFamily == "nvidia_parakeet" || requestParams.ModelFamily == "nvidia_canary" {
|
|
// Both NVIDIA models support multiple European languages
|
|
// No language restriction needed - models support auto-detection
|
|
|
|
// NVIDIA models support diarization via Pyannote integration or NVIDIA Sortformer
|
|
if requestParams.Diarize && requestParams.DiarizeModel == "pyannote" && (requestParams.HfToken == nil || *requestParams.HfToken == "") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Hugging Face token (hf_token) is required for Pyannote diarization"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate multi-track compatibility
|
|
if job.IsMultiTrack && !requestParams.IsMultiTrackEnabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Multi-track audio requires multi-track transcription to be enabled in the parameters"})
|
|
return
|
|
}
|
|
|
|
if !job.IsMultiTrack && requestParams.IsMultiTrackEnabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Multi-track transcription cannot be used with single-track audio files"})
|
|
return
|
|
}
|
|
|
|
// Multi-track transcription should automatically disable diarization
|
|
if requestParams.IsMultiTrackEnabled && requestParams.Diarize {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Diarization must be disabled when using multi-track transcription"})
|
|
return
|
|
}
|
|
|
|
// Update job with parameters
|
|
job.Parameters = requestParams
|
|
job.Diarization = requestParams.Diarize
|
|
job.Status = models.StatusPending
|
|
|
|
// Clear previous results for re-transcription
|
|
job.Transcript = nil
|
|
job.Summary = nil
|
|
job.ErrorMessage = nil
|
|
|
|
// Save updated job
|
|
if err := database.DB.Save(&job).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update job"})
|
|
return
|
|
}
|
|
|
|
// Enqueue job for transcription
|
|
if err := h.taskQueue.EnqueueJob(jobID); err != nil {
|
|
logger.Error("Failed to enqueue job", "job_id", jobID, "error", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enqueue job"})
|
|
return
|
|
}
|
|
|
|
// Log job started
|
|
params := make(map[string]any)
|
|
params["model"] = requestParams.Model
|
|
params["model_family"] = requestParams.ModelFamily
|
|
params["diarization"] = requestParams.Diarize
|
|
if requestParams.Diarize && requestParams.DiarizeModel != "" {
|
|
params["diarize_model"] = requestParams.DiarizeModel
|
|
}
|
|
params["language"] = requestParams.Language
|
|
params["device"] = requestParams.Device
|
|
|
|
filename := filepath.Base(job.AudioPath)
|
|
logger.JobStarted(jobID, filename, requestParams.ModelFamily, params)
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Kill running transcription job
|
|
// @Description Cancel a currently running transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/kill [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) KillJob(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
|
|
return
|
|
}
|
|
|
|
// Check if job is currently processing
|
|
if job.Status != models.StatusProcessing {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Job is not currently running"})
|
|
return
|
|
}
|
|
|
|
// Attempt to kill the job
|
|
if err := h.taskQueue.KillJob(jobID); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Job cancellation requested"})
|
|
}
|
|
|
|
// UpdateTranscriptionTitle updates the title of a transcription job
|
|
// @Summary Update transcription title
|
|
// @Description Update the title of an audio file / transcription
|
|
// @Tags transcription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Param request body map[string]string true "Title update request"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/title [put]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) UpdateTranscriptionTitle(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
if jobID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Job ID required"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Title string `json:"title" binding:"required,min=1,max=255"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
job, err := h.jobRepo.FindByID(c.Request.Context(), jobID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
|
|
job.Title = &body.Title
|
|
if err := h.jobRepo.Update(c.Request.Context(), job); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update title"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": job.ID,
|
|
"title": job.Title,
|
|
"status": job.Status,
|
|
"created_at": job.CreatedAt,
|
|
"audio_path": job.AudioPath,
|
|
})
|
|
}
|
|
|
|
// @Summary Delete transcription job
|
|
// @Description Delete a transcription job and its associated files
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id} [delete]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
// @Summary Delete transcription job
|
|
// @Description Delete a transcription job and its associated files
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id} [delete]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) DeleteTranscriptionJob(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
job, err := h.jobRepo.FindByID(c.Request.Context(), jobID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
|
|
// Prevent deletion of jobs that are currently processing
|
|
if job.Status == models.StatusProcessing {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Cannot delete job that is currently processing"})
|
|
return
|
|
}
|
|
|
|
// Delete files
|
|
if job.IsMultiTrack && job.MultiTrackFolder != nil {
|
|
h.fileService.RemoveDirectory(*job.MultiTrackFolder)
|
|
} else {
|
|
h.fileService.RemoveFile(job.AudioPath)
|
|
}
|
|
|
|
// Also remove .aup file if exists
|
|
if job.AupFilePath != nil {
|
|
h.fileService.RemoveFile(*job.AupFilePath)
|
|
}
|
|
|
|
// Manually delete related records to handle legacy DBs without CASCADE constraints
|
|
// 1. Delete Chat Sessions (and their messages via GORM hooks or manual if needed, but let's assume messages are cascaded by session deletion or we delete them too)
|
|
// Actually, we should use the repositories if available, or direct DB calls if not exposed.
|
|
// Since we have repositories, let's try to use them or add methods.
|
|
// However, for speed and robustness here, we can use the jobRepo's DB instance if we had access, but we don't directly.
|
|
// We should add DeleteByJobID methods to repositories or use a transaction.
|
|
// Given the constraints, let's add a helper in jobRepo or just rely on the fact that we can't easily access other repos here without adding them to Handler if they aren't already.
|
|
// Wait, Handler HAS all repos.
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Delete Chat Sessions
|
|
// We need a method in ChatRepository to delete by JobID or TranscriptionID
|
|
if err := h.chatRepo.DeleteByJobID(ctx, jobID); err != nil {
|
|
// Log error but continue? Or fail? Best to try to clean up as much as possible.
|
|
fmt.Printf("Failed to delete chat sessions for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete Notes
|
|
if err := h.noteRepo.DeleteByTranscriptionID(ctx, jobID); err != nil {
|
|
fmt.Printf("Failed to delete notes for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete Summaries
|
|
if err := h.summaryRepo.DeleteByTranscriptionID(ctx, jobID); err != nil {
|
|
fmt.Printf("Failed to delete summaries for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete Speaker Mappings
|
|
if err := h.speakerMappingRepo.DeleteByJobID(ctx, jobID); err != nil {
|
|
fmt.Printf("Failed to delete speaker mappings for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete Job Executions
|
|
if err := h.jobRepo.DeleteExecutionsByJobID(ctx, jobID); err != nil {
|
|
fmt.Printf("Failed to delete job executions for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete MultiTrack Files (DB records)
|
|
if err := h.jobRepo.DeleteMultiTrackFilesByJobID(ctx, jobID); err != nil {
|
|
fmt.Printf("Failed to delete multi-track file records for job %s: %v\n", jobID, err)
|
|
}
|
|
|
|
// Delete from database
|
|
if err := h.jobRepo.Delete(c.Request.Context(), jobID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete job: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Job deleted successfully"})
|
|
}
|
|
|
|
// @Summary Get transcription job execution data
|
|
// @Description Get execution parameters and timing for a transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} models.TranscriptionJobExecution
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/execution [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetJobExecutionData(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
// Get the transcription job to check if it's multi-track
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Preload("MultiTrackFiles").Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Transcription job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get transcription job"})
|
|
return
|
|
}
|
|
|
|
var execution models.TranscriptionJobExecution
|
|
if err := database.DB.Where("transcription_job_id = ? AND status = ?", jobID, models.StatusCompleted).
|
|
Order("completed_at DESC").
|
|
First(&execution).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No completed execution found for this job"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get execution data"})
|
|
return
|
|
}
|
|
|
|
// Create enhanced response with multi-track data
|
|
response := gin.H{
|
|
"id": execution.ID,
|
|
"transcription_job_id": execution.TranscriptionJobID,
|
|
"started_at": execution.StartedAt,
|
|
"completed_at": execution.CompletedAt,
|
|
"processing_duration": execution.ProcessingDuration,
|
|
"actual_parameters": execution.ActualParameters,
|
|
"status": execution.Status,
|
|
"error_message": execution.ErrorMessage,
|
|
"created_at": execution.CreatedAt,
|
|
"updated_at": execution.UpdatedAt,
|
|
"is_multi_track": job.IsMultiTrack,
|
|
}
|
|
|
|
// Add multi-track specific data if available
|
|
if job.IsMultiTrack && execution.MultiTrackTimings != nil {
|
|
// Deserialize track timings
|
|
var trackTimings []models.MultiTrackTiming
|
|
if err := json.Unmarshal([]byte(*execution.MultiTrackTimings), &trackTimings); err == nil {
|
|
response["multi_track_timings"] = trackTimings
|
|
}
|
|
|
|
// Add merge timing data
|
|
response["merge_start_time"] = execution.MergeStartTime
|
|
response["merge_end_time"] = execution.MergeEndTime
|
|
response["merge_duration"] = execution.MergeDuration
|
|
|
|
// Add multi-track files information
|
|
response["multi_track_files"] = job.MultiTrackFiles
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Get audio file
|
|
// @Description Serve the audio file for a transcription job
|
|
// @Tags transcription
|
|
// @Produce audio/mpeg,audio/wav,audio/mp4
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {file} binary
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/{id}/audio [get]
|
|
// @Security ApiKeyAuth
|
|
func (h *Handler) GetAudioFile(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
var job models.TranscriptionJob
|
|
if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job"})
|
|
return
|
|
}
|
|
|
|
// Debug logging
|
|
fmt.Printf("DEBUG: GetAudioFile for job %s\n", jobID)
|
|
fmt.Printf("DEBUG: Job status: %s\n", job.Status)
|
|
fmt.Printf("DEBUG: Audio path: '%s'\n", job.AudioPath)
|
|
|
|
// For multi-track jobs, prefer merged audio if available
|
|
audioPath := job.AudioPath
|
|
if job.IsMultiTrack && job.MergedAudioPath != nil && *job.MergedAudioPath != "" {
|
|
// Check if merged audio file exists
|
|
if _, err := os.Stat(*job.MergedAudioPath); err == nil {
|
|
audioPath = *job.MergedAudioPath
|
|
fmt.Printf("DEBUG: Using merged audio: %s\n", audioPath)
|
|
} else {
|
|
fmt.Printf("DEBUG: Merged audio not found, falling back to original: %s\n", job.AudioPath)
|
|
}
|
|
}
|
|
|
|
// Check if audio file exists
|
|
if audioPath == "" {
|
|
fmt.Printf("DEBUG: Audio path is empty\n")
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Audio file path not found"})
|
|
return
|
|
}
|
|
|
|
// Check if file exists on filesystem
|
|
if _, err := os.Stat(audioPath); os.IsNotExist(err) {
|
|
fmt.Printf("DEBUG: Audio file does not exist on disk: %s\n", audioPath)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Audio file not found on disk"})
|
|
return
|
|
}
|
|
|
|
fmt.Printf("DEBUG: Audio file exists, serving: %s\n", audioPath)
|
|
|
|
// Set appropriate content type based on file extension
|
|
ext := filepath.Ext(job.AudioPath)
|
|
switch ext {
|
|
case ".mp3":
|
|
c.Header("Content-Type", "audio/mpeg")
|
|
case ".wav":
|
|
c.Header("Content-Type", "audio/wav")
|
|
case ".m4a":
|
|
c.Header("Content-Type", "audio/mp4")
|
|
case ".ogg":
|
|
c.Header("Content-Type", "audio/ogg")
|
|
default:
|
|
c.Header("Content-Type", "audio/mpeg")
|
|
}
|
|
|
|
// Add CORS headers for audio visualization and streaming
|
|
origin := c.Request.Header.Get("Origin")
|
|
if origin != "" {
|
|
c.Header("Access-Control-Allow-Origin", origin)
|
|
c.Header("Access-Control-Allow-Credentials", "true")
|
|
} else {
|
|
// Fallback for non-browser/direct access
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|
}
|
|
c.Header("Access-Control-Expose-Headers", "Content-Range, Accept-Ranges, Content-Length")
|
|
c.Header("Accept-Ranges", "bytes")
|
|
|
|
// Open the file
|
|
file, err := os.Open(audioPath)
|
|
if err != nil {
|
|
fmt.Printf("ERROR: Failed to open audio file: %v\n", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open audio file"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file stats
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
fmt.Printf("ERROR: Failed to stat audio file: %v\n", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to stat audio file"})
|
|
return
|
|
}
|
|
|
|
// Use http.ServeContent for efficient streaming and range request support
|
|
http.ServeContent(c.Writer, c.Request, filepath.Base(audioPath), fileInfo.ModTime(), file)
|
|
}
|
|
|
|
// @Summary Login
|
|
// @Description Authenticate user and return JWT token
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param credentials body LoginRequest true "User credentials"
|
|
// @Success 200 {object} LoginResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/login [post]
|
|
func (h *Handler) Login(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
|
return
|
|
}
|
|
|
|
var user models.User
|
|
if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
|
logger.AuthEvent("login", req.Username, c.ClientIP(), false, "user_not_found")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
if !auth.CheckPassword(req.Password, user.Password) {
|
|
logger.AuthEvent("login", req.Username, c.ClientIP(), false, "invalid_password")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
token, err := h.authService.GenerateToken(&user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
// Set refresh token cookie
|
|
if err := h.issueRefreshToken(c, user.ID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
|
return
|
|
}
|
|
|
|
// Set access token cookie for streaming/media access
|
|
// We use Lax mode to allow top-level navigation authentication if needed, but Strict is safer for API.
|
|
// Since we use this for <audio> src which is a cross-origin-like request (even if same origin technically),
|
|
// SameSite=Strict should work for same-site.
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: "scriberr_access_token",
|
|
Value: token,
|
|
Path: "/",
|
|
Expires: time.Now().Add(24 * time.Hour), // Match your token duration constant
|
|
HttpOnly: true,
|
|
Secure: false, // Set to true in production with HTTPS
|
|
SameSite: http.SameSiteStrictMode,
|
|
})
|
|
|
|
response := LoginResponse{Token: token}
|
|
response.User.ID = user.ID
|
|
response.User.Username = user.Username
|
|
|
|
logger.AuthEvent("login", req.Username, c.ClientIP(), true)
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Logout user
|
|
// @Description Logout user and invalidate token (client-side action)
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/auth/logout [post]
|
|
func (h *Handler) Logout(c *gin.Context) {
|
|
// Best-effort refresh token revocation and cookie clear
|
|
if cookie, err := c.Cookie("scriberr_refresh_token"); err == nil {
|
|
h.revokeRefreshToken(cookie)
|
|
}
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: "scriberr_refresh_token",
|
|
Value: "",
|
|
Path: "/",
|
|
Expires: time.Unix(0, 0),
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: false,
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
|
}
|
|
|
|
// @Summary Check registration status
|
|
// @Description Check if the application requires initial user registration
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 200 {object} RegistrationStatusResponse
|
|
// @Router /api/v1/auth/registration-status [get]
|
|
func (h *Handler) GetRegistrationStatus(c *gin.Context) {
|
|
var userCount int64
|
|
if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check registration status"})
|
|
return
|
|
}
|
|
|
|
response := RegistrationStatusResponse{
|
|
RegistrationEnabled: userCount == 0,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Register initial admin user
|
|
// @Description Register the initial admin user (only allowed when no users exist)
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body RegisterRequest true "Registration details"
|
|
// @Success 201 {object} LoginResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 409 {object} map[string]string
|
|
// @Router /api/v1/auth/register [post]
|
|
func (h *Handler) Register(c *gin.Context) {
|
|
// Check if any users already exist
|
|
var userCount int64
|
|
if err := database.DB.Model(&models.User{}).Count(&userCount).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing users"})
|
|
return
|
|
}
|
|
|
|
if userCount > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Registration is not allowed. Admin user already exists"})
|
|
return
|
|
}
|
|
|
|
var req RegisterRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate password confirmation
|
|
if req.Password != req.ConfirmPassword {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Passwords do not match"})
|
|
return
|
|
}
|
|
|
|
// Hash password
|
|
hashedPassword, err := auth.HashPassword(req.Password)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to secure password"})
|
|
return
|
|
}
|
|
|
|
// Create user
|
|
user := models.User{
|
|
Username: req.Username,
|
|
Password: hashedPassword,
|
|
}
|
|
|
|
if err := database.DB.Create(&user).Error; err != nil {
|
|
if database.DB.Error.Error() == "UNIQUE constraint failed: users.username" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
return
|
|
}
|
|
|
|
// Generate token for immediate login
|
|
token, err := h.authService.GenerateToken(&user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate login token"})
|
|
return
|
|
}
|
|
// Set refresh token cookie
|
|
if err := h.issueRefreshToken(c, user.ID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
|
return
|
|
}
|
|
response := LoginResponse{Token: token}
|
|
response.User.ID = user.ID
|
|
response.User.Username = user.Username
|
|
|
|
c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
// RefreshTokenResponse represents the refresh response
|
|
type RefreshTokenResponse struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// @Summary Refresh access token
|
|
// @Description Rotate refresh token and return new access token
|
|
// @Tags auth
|
|
// @Produce json
|
|
// @Success 200 {object} RefreshTokenResponse
|
|
// @Failure 401 {object} map[string]string
|
|
// @Router /api/v1/auth/refresh [post]
|
|
func (h *Handler) Refresh(c *gin.Context) {
|
|
cookie, err := c.Cookie("scriberr_refresh_token")
|
|
if err != nil || cookie == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing refresh token"})
|
|
return
|
|
}
|
|
userID, err := h.validateAndRotateRefreshToken(c, cookie)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
|
|
return
|
|
}
|
|
var user models.User
|
|
if err := database.DB.First(&user, userID).Error; err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
|
return
|
|
}
|
|
token, err := h.authService.GenerateToken(&user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, RefreshTokenResponse{Token: token})
|
|
}
|
|
|
|
// issueRefreshToken creates a refresh token and sets cookie
|
|
func (h *Handler) issueRefreshToken(c *gin.Context, userID uint) error {
|
|
tokenValue := generateSecureAPIKey(64)
|
|
hashed := sha256Hex(tokenValue)
|
|
rt := models.RefreshToken{
|
|
UserID: userID,
|
|
Hashed: hashed,
|
|
ExpiresAt: time.Now().Add(14 * 24 * time.Hour),
|
|
Revoked: false,
|
|
}
|
|
if err := database.DB.Create(&rt).Error; err != nil {
|
|
return err
|
|
}
|
|
http.SetCookie(c.Writer, &http.Cookie{
|
|
Name: "scriberr_refresh_token",
|
|
Value: tokenValue,
|
|
Path: "/",
|
|
Expires: rt.ExpiresAt,
|
|
MaxAge: int((14 * 24 * time.Hour).Seconds()),
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
Secure: false,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// validateAndRotateRefreshToken validates refresh token, revokes old, and issues new
|
|
func (h *Handler) validateAndRotateRefreshToken(c *gin.Context, tokenValue string) (uint, error) {
|
|
hashed := sha256Hex(tokenValue)
|
|
var rt models.RefreshToken
|
|
if err := database.DB.Where("hashed = ?", hashed).First(&rt).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
if rt.Revoked || time.Now().After(rt.ExpiresAt) {
|
|
return 0, fmt.Errorf("expired or revoked")
|
|
}
|
|
// Revoke current
|
|
_ = database.DB.Model(&rt).Update("revoked", true).Error
|
|
// Issue new
|
|
if err := h.issueRefreshToken(c, rt.UserID); err != nil {
|
|
return 0, err
|
|
}
|
|
return rt.UserID, nil
|
|
}
|
|
|
|
func (h *Handler) revokeRefreshToken(tokenValue string) {
|
|
hashed := sha256Hex(tokenValue)
|
|
_ = database.DB.Model(&models.RefreshToken{}).Where("hashed = ?", hashed).Update("revoked", true).Error
|
|
}
|
|
|
|
func sha256Hex(s string) string {
|
|
sum := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// @Summary Change user password
|
|
// @Description Change the current user's password
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body ChangePasswordRequest true "Password change details"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/auth/change-password [post]
|
|
func (h *Handler) ChangePassword(c *gin.Context) {
|
|
// Get user ID from context (set by auth middleware)
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
var req ChangePasswordRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate password confirmation
|
|
if req.NewPassword != req.ConfirmPassword {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "New passwords do not match"})
|
|
return
|
|
}
|
|
|
|
// Use UserService to change password
|
|
if err := h.userService.ChangePassword(c.Request.Context(), userID.(uint), req.CurrentPassword, req.NewPassword); err != nil {
|
|
if err.Error() == "incorrect password" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update password"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
|
|
}
|
|
|
|
// @Summary Change username
|
|
// @Description Change the current user's username
|
|
// @Tags auth
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body ChangeUsernameRequest true "Username change details"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 409 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/auth/change-username [post]
|
|
func (h *Handler) ChangeUsername(c *gin.Context) {
|
|
// Get user ID from context (set by auth middleware)
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
var req ChangeUsernameRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Use UserService to change username
|
|
if err := h.userService.ChangeUsername(c.Request.Context(), userID.(uint), req.Password, req.NewUsername); err != nil {
|
|
if err.Error() == "incorrect password" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Password is incorrect"})
|
|
return
|
|
}
|
|
if err.Error() == "username already exists" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Username already exists"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update username"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Username changed successfully"})
|
|
}
|
|
|
|
// @Summary List API keys
|
|
// @Description Get all API keys for the current user (without exposing the actual keys)
|
|
// @Tags api-keys
|
|
// @Produce json
|
|
// @Success 200 {object} APIKeysWrapper
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/api-keys [get]
|
|
func (h *Handler) ListAPIKeys(c *gin.Context) {
|
|
apiKeys, err := h.apiKeyRepo.ListActive(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch API keys"})
|
|
return
|
|
}
|
|
|
|
// Transform API keys to list response format
|
|
var responseKeys []APIKeyListResponse
|
|
for _, apiKey := range apiKeys {
|
|
responseKeys = append(responseKeys, transformAPIKeyForList(apiKey))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, APIKeysWrapper{APIKeys: responseKeys})
|
|
}
|
|
|
|
// @Summary Create API key
|
|
// @Description Create a new API key for external API access
|
|
// @Tags api-keys
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body CreateAPIKeyRequest true "API key creation details"
|
|
// @Success 201 {object} CreateAPIKeyResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/api-keys [post]
|
|
func (h *Handler) CreateAPIKey(c *gin.Context) {
|
|
var req CreateAPIKeyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Generate a secure API key
|
|
apiKey := generateSecureAPIKey(32)
|
|
|
|
// Create the API key record
|
|
newKey := models.APIKey{
|
|
Key: apiKey,
|
|
Name: req.Name,
|
|
Description: &req.Description,
|
|
IsActive: true,
|
|
}
|
|
|
|
if err := h.apiKeyRepo.Create(c.Request.Context(), &newKey); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key"})
|
|
return
|
|
}
|
|
|
|
// Return full model with 200 to match tests
|
|
c.JSON(http.StatusOK, newKey)
|
|
}
|
|
|
|
// @Summary Delete API key
|
|
// @Description Delete an API key
|
|
// @Tags api-keys
|
|
// @Param id path int true "API Key ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/api-keys/{id} [delete]
|
|
func (h *Handler) DeleteAPIKey(c *gin.Context) {
|
|
idParam := c.Param("id")
|
|
id, err := strconv.ParseUint(idParam, 10, 32)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid API key ID"})
|
|
return
|
|
}
|
|
|
|
// Check if the API key exists
|
|
_, err = h.apiKeyRepo.FindByID(c.Request.Context(), uint(id))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "API key not found"})
|
|
return
|
|
}
|
|
|
|
// Delete the API key (soft delete by setting is_active to false)
|
|
if err := h.apiKeyRepo.Revoke(c.Request.Context(), uint(id)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete API key"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "API key deleted successfully"})
|
|
}
|
|
|
|
// @Summary Get LLM configuration
|
|
// @Description Get the current active LLM configuration
|
|
// @Tags llm
|
|
// @Produce json
|
|
// @Success 200 {object} LLMConfigResponse
|
|
// @Failure 404 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/llm/config [get]
|
|
func (h *Handler) GetLLMConfig(c *gin.Context) {
|
|
config, err := h.llmConfigRepo.GetActive(c.Request.Context())
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No active LLM configuration found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch LLM configuration"})
|
|
return
|
|
}
|
|
|
|
response := LLMConfigResponse{
|
|
ID: config.ID,
|
|
Provider: config.Provider,
|
|
BaseURL: config.BaseURL,
|
|
OpenAIBaseURL: config.OpenAIBaseURL,
|
|
HasAPIKey: config.APIKey != nil && *config.APIKey != "",
|
|
IsActive: config.IsActive,
|
|
CreatedAt: config.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Create or update LLM configuration
|
|
// @Description Create or update LLM configuration settings
|
|
// @Tags llm
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body LLMConfigRequest true "LLM configuration details"
|
|
// @Success 200 {object} LLMConfigResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/llm/config [post]
|
|
func (h *Handler) SaveLLMConfig(c *gin.Context) {
|
|
var req LLMConfigRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate provider-specific requirements
|
|
if req.Provider == "ollama" && (req.BaseURL == nil || *req.BaseURL == "") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Base URL is required for Ollama provider"})
|
|
return
|
|
}
|
|
|
|
// Check if there's an existing active configuration
|
|
existingConfig, err := h.llmConfigRepo.GetActive(c.Request.Context())
|
|
if err != nil && err != gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing configuration"})
|
|
return
|
|
}
|
|
|
|
// Handle API Key logic for OpenAI
|
|
var apiKeyToSave *string
|
|
if req.Provider == "openai" {
|
|
if req.APIKey != nil && *req.APIKey != "" {
|
|
// New key provided
|
|
apiKeyToSave = req.APIKey
|
|
} else if existingConfig != nil && existingConfig.APIKey != nil && *existingConfig.APIKey != "" {
|
|
// Reuse existing key
|
|
apiKeyToSave = existingConfig.APIKey
|
|
} else {
|
|
// No key provided and no existing key
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "API key is required for OpenAI provider"})
|
|
return
|
|
}
|
|
}
|
|
|
|
var config *models.LLMConfig
|
|
|
|
if err == gorm.ErrRecordNotFound {
|
|
// No existing active config, create new one
|
|
config = &models.LLMConfig{
|
|
Provider: req.Provider,
|
|
BaseURL: req.BaseURL,
|
|
OpenAIBaseURL: req.OpenAIBaseURL,
|
|
APIKey: apiKeyToSave,
|
|
IsActive: req.IsActive,
|
|
}
|
|
|
|
if err := h.llmConfigRepo.Create(c.Request.Context(), config); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create LLM configuration"})
|
|
return
|
|
}
|
|
} else {
|
|
// Update existing config
|
|
existingConfig.Provider = req.Provider
|
|
existingConfig.BaseURL = req.BaseURL
|
|
existingConfig.OpenAIBaseURL = req.OpenAIBaseURL
|
|
existingConfig.APIKey = apiKeyToSave
|
|
existingConfig.IsActive = req.IsActive
|
|
|
|
if err := h.llmConfigRepo.Update(c.Request.Context(), existingConfig); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update LLM configuration"})
|
|
return
|
|
}
|
|
config = existingConfig
|
|
}
|
|
|
|
response := LLMConfigResponse{
|
|
ID: config.ID,
|
|
Provider: config.Provider,
|
|
BaseURL: config.BaseURL,
|
|
OpenAIBaseURL: config.OpenAIBaseURL,
|
|
HasAPIKey: config.APIKey != nil && *config.APIKey != "",
|
|
IsActive: config.IsActive,
|
|
CreatedAt: config.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
UpdatedAt: config.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// generateSecureAPIKey generates a cryptographically secure API key
|
|
func generateSecureAPIKey(length int) string {
|
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
b := make([]byte, length)
|
|
randomBytes := make([]byte, length)
|
|
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
// Fallback to a UUID if crypto/rand fails
|
|
return uuid.New().String()
|
|
}
|
|
|
|
for i := range b {
|
|
b[i] = charset[randomBytes[i]%byte(len(charset))]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// @Summary Get queue statistics
|
|
// @Description Get current queue statistics
|
|
// @Tags admin
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/admin/queue/stats [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetQueueStats(c *gin.Context) {
|
|
stats := h.taskQueue.GetQueueStats()
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// @Summary Get supported models
|
|
// @Description Get list of supported WhisperX models
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/transcription/models [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetSupportedModels(c *gin.Context) {
|
|
models := h.unifiedProcessor.GetSupportedModels()
|
|
languages := h.unifiedProcessor.GetSupportedLanguages()
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"models": models,
|
|
"languages": languages,
|
|
})
|
|
}
|
|
|
|
// Health check endpoint
|
|
// @Summary Health check
|
|
// @Description Check if the API is healthy
|
|
// @Tags health
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]string
|
|
// @Router /health [get]
|
|
func (h *Handler) HealthCheck(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "healthy",
|
|
"version": "1.0.0",
|
|
})
|
|
}
|
|
|
|
// Helper functions
|
|
func getFormValueWithDefault(c *gin.Context, key, defaultValue string) string {
|
|
if value := c.PostForm(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getFormIntWithDefault(c *gin.Context, key string, defaultValue int) int {
|
|
if value := c.PostForm(key); value != "" {
|
|
if intValue, err := strconv.Atoi(value); err == nil {
|
|
return intValue
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getFormFloatWithDefault(c *gin.Context, key string, defaultValue float64) float64 {
|
|
if value := c.PostForm(key); value != "" {
|
|
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
|
|
return floatValue
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
func getFormBoolWithDefault(c *gin.Context, key string, defaultValue bool) bool {
|
|
if value := c.PostForm(key); value != "" {
|
|
if boolValue, err := strconv.ParseBool(value); err == nil {
|
|
return boolValue
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|
|
|
|
// Profile API Handlers
|
|
|
|
// @Summary List transcription profiles
|
|
// @Description Get list of all transcription profiles
|
|
// @Tags profiles
|
|
// @Produce json
|
|
// @Success 200 {array} models.TranscriptionProfile
|
|
// @Router /api/v1/profiles [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) ListProfiles(c *gin.Context) {
|
|
// TODO: Add pagination support to API if needed. For now, list all (limit 1000)
|
|
profiles, _, err := h.profileRepo.List(c.Request.Context(), 0, 1000)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch profiles"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profiles)
|
|
}
|
|
|
|
// @Summary Create transcription profile
|
|
// @Description Create a new transcription profile
|
|
// @Tags profiles
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param profile body models.TranscriptionProfile true "Profile data"
|
|
// @Success 201 {object} models.TranscriptionProfile
|
|
// @Failure 400 {object} map[string]string
|
|
// @Router /api/v1/profiles [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) CreateProfile(c *gin.Context) {
|
|
var profile models.TranscriptionProfile
|
|
if err := c.ShouldBindJSON(&profile); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if profile.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name is required"})
|
|
return
|
|
}
|
|
|
|
// Check if profile name already exists
|
|
// TODO: Add FindByName to ProfileRepository if needed, or rely on unique constraint error
|
|
// For now, we'll skip explicit check or implement it in repository.
|
|
// Assuming unique constraint on Name in DB or we can check via List.
|
|
|
|
if err := h.profileRepo.Create(c.Request.Context(), &profile); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create profile"})
|
|
return
|
|
}
|
|
|
|
// Tests expect 200 on create
|
|
c.JSON(http.StatusOK, profile)
|
|
}
|
|
|
|
// @Summary Get transcription profile
|
|
// @Description Get a transcription profile by ID
|
|
// @Tags profiles
|
|
// @Produce json
|
|
// @Param id path string true "Profile ID"
|
|
// @Success 200 {object} models.TranscriptionProfile
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/profiles/{id} [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
|
|
profile, err := h.profileRepo.FindByID(c.Request.Context(), profileID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, profile)
|
|
}
|
|
|
|
// @Summary Update transcription profile
|
|
// @Description Update a transcription profile
|
|
// @Tags profiles
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Profile ID"
|
|
// @Param profile body models.TranscriptionProfile true "Updated profile data"
|
|
// @Success 200 {object} models.TranscriptionProfile
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/profiles/{id} [put]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) UpdateProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
|
|
existingProfile, err := h.profileRepo.FindByID(c.Request.Context(), profileID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
var updatedProfile models.TranscriptionProfile
|
|
if err := c.ShouldBindJSON(&updatedProfile); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"})
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
if updatedProfile.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Profile name is required"})
|
|
return
|
|
}
|
|
|
|
// Check if profile name already exists (excluding current profile)
|
|
// TODO: Add check to repository
|
|
|
|
// Update the profile
|
|
// We need to preserve ID and CreatedAt, and update other fields
|
|
// GORM Save updates all fields.
|
|
updatedProfile.ID = existingProfile.ID
|
|
updatedProfile.CreatedAt = existingProfile.CreatedAt
|
|
|
|
if err := h.profileRepo.Update(c.Request.Context(), &updatedProfile); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, updatedProfile)
|
|
}
|
|
|
|
// @Summary Delete transcription profile
|
|
// @Description Delete a transcription profile
|
|
// @Tags profiles
|
|
// @Produce json
|
|
// @Param id path string true "Profile ID"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/profiles/{id} [delete]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) DeleteProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
|
|
_, err := h.profileRepo.FindByID(c.Request.Context(), profileID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
if err := h.profileRepo.Delete(c.Request.Context(), profileID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Profile deleted successfully"})
|
|
}
|
|
|
|
// SetDefaultProfile sets a profile as the default profile
|
|
// @Summary Set default transcription profile
|
|
// @Description Mark the specified profile as the default profile
|
|
// @Tags profiles
|
|
// @Produce json
|
|
// @Param id path string true "Profile ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/profiles/{id}/set-default [post]
|
|
func (h *Handler) SetDefaultProfile(c *gin.Context) {
|
|
profileID := c.Param("id")
|
|
if profileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Profile ID is required"})
|
|
return
|
|
}
|
|
|
|
// Find the profile
|
|
profile, err := h.profileRepo.FindByID(c.Request.Context(), profileID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
// Set this profile as default (the BeforeSave hook will handle unsetting other defaults)
|
|
profile.IsDefault = true
|
|
if err := h.profileRepo.Update(c.Request.Context(), profile); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set default profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Default profile set successfully", "profile": profile})
|
|
}
|
|
|
|
// QuickTranscriptionRequest represents the quick transcription request
|
|
type QuickTranscriptionRequest struct {
|
|
Parameters *models.WhisperXParams `json:"parameters,omitempty"`
|
|
ProfileName *string `json:"profile_name,omitempty"`
|
|
}
|
|
|
|
// @Summary Submit quick transcription job
|
|
// @Description Submit an audio file for temporary transcription (data discarded after 6 hours)
|
|
// @Tags transcription
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Param audio formData file true "Audio file"
|
|
// @Param parameters formData string false "JSON string of transcription parameters"
|
|
// @Param profile_name formData string false "Profile name to use for transcription"
|
|
// @Success 200 {object} transcription.QuickTranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/quick [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) SubmitQuickTranscription(c *gin.Context) {
|
|
// Parse multipart form
|
|
file, header, err := c.Request.FormFile("audio")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Audio file is required"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
var params models.WhisperXParams
|
|
|
|
// Check if profile_name was provided
|
|
if profileName := c.PostForm("profile_name"); profileName != "" {
|
|
// Load parameters from profile
|
|
var profile models.TranscriptionProfile
|
|
if err := database.DB.Where("name = ?", profileName).First(&profile).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Profile '%s' not found", profileName)})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load profile"})
|
|
return
|
|
}
|
|
params = profile.Parameters
|
|
} else if parametersJSON := c.PostForm("parameters"); parametersJSON != "" {
|
|
// Parse parameters from JSON string
|
|
if err := json.Unmarshal([]byte(parametersJSON), ¶ms); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid parameters JSON"})
|
|
return
|
|
}
|
|
} else {
|
|
// Use default parameters with all required fields
|
|
params = models.WhisperXParams{
|
|
// Model parameters
|
|
Model: "small",
|
|
ModelCacheOnly: false,
|
|
|
|
// Device and computation
|
|
Device: "cpu",
|
|
DeviceIndex: 0,
|
|
BatchSize: 8,
|
|
ComputeType: "float32",
|
|
Threads: 0,
|
|
|
|
// Output settings
|
|
OutputFormat: "all",
|
|
Verbose: true,
|
|
|
|
// Task and language
|
|
Task: "transcribe",
|
|
|
|
// Alignment settings
|
|
InterpolateMethod: "nearest",
|
|
NoAlign: false,
|
|
ReturnCharAlignments: false,
|
|
|
|
// VAD (Voice Activity Detection) settings
|
|
VadMethod: "pyannote",
|
|
VadOnset: 0.5,
|
|
VadOffset: 0.363,
|
|
ChunkSize: 30,
|
|
|
|
// Diarization settings
|
|
Diarize: false,
|
|
DiarizeModel: "pyannote/speaker-diarization-3.1",
|
|
SpeakerEmbeddings: false,
|
|
|
|
// Transcription quality settings
|
|
Temperature: 0,
|
|
BestOf: 5,
|
|
BeamSize: 5,
|
|
Patience: 1.0,
|
|
LengthPenalty: 1.0,
|
|
SuppressNumerals: false,
|
|
ConditionOnPreviousText: false,
|
|
Fp16: true,
|
|
TemperatureIncrementOnFallback: 0.2,
|
|
CompressionRatioThreshold: 2.4,
|
|
LogprobThreshold: -1.0,
|
|
NoSpeechThreshold: 0.6,
|
|
|
|
// Output formatting
|
|
HighlightWords: false,
|
|
SegmentResolution: "sentence",
|
|
PrintProgress: false,
|
|
}
|
|
}
|
|
|
|
// Submit quick transcription job
|
|
job, err := h.quickTranscription.SubmitQuickJob(file, header.Filename, params)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to submit quick transcription: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Get quick transcription status
|
|
// @Description Get the current status of a quick transcription job
|
|
// @Tags transcription
|
|
// @Produce json
|
|
// @Param id path string true "Job ID"
|
|
// @Success 200 {object} transcription.QuickTranscriptionJob
|
|
// @Failure 404 {object} map[string]string
|
|
// @Router /api/v1/transcription/quick/{id} [get]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) GetQuickTranscriptionStatus(c *gin.Context) {
|
|
jobID := c.Param("id")
|
|
|
|
job, err := h.quickTranscription.GetQuickJob(jobID)
|
|
if err != nil {
|
|
if err.Error() == "job not found" || err.Error() == "job expired" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get job status"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Download audio from YouTube URL
|
|
// @Description Download audio from a YouTube video URL and prepare it for transcription
|
|
// @Tags transcription
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body YouTubeDownloadRequest true "YouTube download request"
|
|
// @Success 200 {object} models.TranscriptionJob
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Router /api/v1/transcription/youtube [post]
|
|
// @Security ApiKeyAuth
|
|
// @Security BearerAuth
|
|
func (h *Handler) DownloadFromYouTube(c *gin.Context) {
|
|
var req YouTubeDownloadRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate YouTube URL
|
|
if !strings.Contains(req.URL, "youtube.com") && !strings.Contains(req.URL, "youtu.be") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid YouTube URL"})
|
|
return
|
|
}
|
|
|
|
// Create upload directory
|
|
uploadDir := h.config.UploadDir
|
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
|
|
return
|
|
}
|
|
|
|
// Generate unique job ID and filename
|
|
jobID := uuid.New().String()
|
|
filename := fmt.Sprintf("%s.%%(ext)s", jobID)
|
|
filePath := filepath.Join(uploadDir, filename)
|
|
|
|
// Get video title if not provided
|
|
var title string
|
|
if req.Title != nil && *req.Title != "" {
|
|
title = *req.Title
|
|
} else {
|
|
// Get title from yt-dlp
|
|
titleStart := time.Now()
|
|
cmd := exec.Command(h.config.UVPath, "run", "--native-tls", "--project", h.config.WhisperXEnv, "python", "-m", "yt_dlp", "--get-title", req.URL)
|
|
titleBytes, err := cmd.Output()
|
|
if err != nil {
|
|
title = "YouTube Audio"
|
|
logger.Warn("Failed to get YouTube title", "url", req.URL, "error", err.Error(), "duration", time.Since(titleStart))
|
|
} else {
|
|
title = strings.TrimSpace(string(titleBytes))
|
|
logger.Info("YouTube title retrieved", "title", title, "duration", time.Since(titleStart))
|
|
}
|
|
}
|
|
|
|
// Download audio using yt-dlp in Python environment
|
|
logger.Info("Starting YouTube download", "url", req.URL, "job_id", jobID)
|
|
downloadStart := time.Now()
|
|
|
|
ytDlpCmd := exec.Command(h.config.UVPath, "run", "--native-tls", "--project", h.config.WhisperXEnv, "python", "-m", "yt_dlp",
|
|
"--extract-audio",
|
|
"--audio-format", "mp3",
|
|
"--audio-quality", "0", // best quality
|
|
"--output", filePath,
|
|
"--no-playlist",
|
|
req.URL,
|
|
)
|
|
|
|
// Execute download and capture stderr for better error messages
|
|
var stderr bytes.Buffer
|
|
ytDlpCmd.Stderr = &stderr
|
|
|
|
if err := ytDlpCmd.Run(); err != nil {
|
|
stderrOutput := stderr.String()
|
|
logger.Error("YouTube download failed",
|
|
"url", req.URL,
|
|
"job_id", jobID,
|
|
"error", err.Error(),
|
|
"stderr", stderrOutput,
|
|
"duration", time.Since(downloadStart))
|
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to download YouTube audio: %v", err),
|
|
"details": stderrOutput,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Find the actual downloaded file (yt-dlp changes the extension)
|
|
pattern := fmt.Sprintf("%s.*", jobID)
|
|
matches, err := filepath.Glob(filepath.Join(uploadDir, pattern))
|
|
if err != nil || len(matches) == 0 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Downloaded file not found"})
|
|
return
|
|
}
|
|
|
|
actualFilePath := matches[0]
|
|
|
|
// Get file size for performance logging
|
|
fileInfo, err := os.Stat(actualFilePath)
|
|
if err == nil {
|
|
fileSizeMB := float64(fileInfo.Size()) / 1024 / 1024
|
|
logger.Info("YouTube download completed",
|
|
"url", req.URL,
|
|
"job_id", jobID,
|
|
"file_path", actualFilePath,
|
|
"file_size_mb", fmt.Sprintf("%.2f", fileSizeMB),
|
|
"duration", time.Since(downloadStart))
|
|
}
|
|
|
|
// Create transcription record
|
|
job := models.TranscriptionJob{
|
|
ID: jobID,
|
|
AudioPath: actualFilePath,
|
|
Status: models.StatusUploaded,
|
|
}
|
|
|
|
// Set title
|
|
if title != "" {
|
|
job.Title = &title
|
|
}
|
|
|
|
// Save to database
|
|
if err := database.DB.Create(&job).Error; err != nil {
|
|
// Clean up downloaded file on database error
|
|
os.Remove(actualFilePath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save transcription record"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, job)
|
|
}
|
|
|
|
// @Summary Get user's default profile
|
|
// @Description Get the default transcription profile for the current user
|
|
// @Tags profiles
|
|
// @Produce json
|
|
// @Success 200 {object} models.TranscriptionProfile
|
|
// @Failure 404 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/user/default-profile [get]
|
|
func (h *Handler) GetUserDefaultProfile(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
// Get user with default profile ID
|
|
user, err := h.userRepo.FindByID(c.Request.Context(), userID.(uint))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
return
|
|
}
|
|
|
|
// If user has no default profile set, return the first available profile or no profile
|
|
if user.DefaultProfileID == nil {
|
|
// Try to find a default profile from profiles table
|
|
profile, err := h.profileRepo.FindDefault(c.Request.Context())
|
|
if err == nil {
|
|
c.JSON(http.StatusOK, profile)
|
|
return
|
|
}
|
|
|
|
// If no default marked, get first one
|
|
profiles, _, err := h.profileRepo.List(c.Request.Context(), 0, 1)
|
|
if err != nil || len(profiles) == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profiles[0])
|
|
return
|
|
}
|
|
|
|
// Get the user's default profile
|
|
profile, err := h.profileRepo.FindByID(c.Request.Context(), fmt.Sprintf("%s", *user.DefaultProfileID))
|
|
if err != nil {
|
|
// Default profile no longer exists, fall back to first available
|
|
profiles, _, err := h.profileRepo.List(c.Request.Context(), 0, 1)
|
|
if err != nil || len(profiles) == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "No profiles available"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, profiles[0])
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, profile)
|
|
}
|
|
|
|
// SetUserDefaultProfileRequest represents the request to set user's default profile
|
|
type SetUserDefaultProfileRequest struct {
|
|
ProfileID string `json:"profile_id" binding:"required"`
|
|
}
|
|
|
|
// @Summary Set user's default profile
|
|
// @Description Set the default transcription profile for the current user
|
|
// @Tags profiles
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body SetUserDefaultProfileRequest true "Default profile request"
|
|
// @Success 200 {object} map[string]string
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 404 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/user/default-profile [post]
|
|
func (h *Handler) SetUserDefaultProfile(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
var req SetUserDefaultProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Verify the profile exists
|
|
_, err := h.profileRepo.FindByID(c.Request.Context(), fmt.Sprintf("%s", req.ProfileID))
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
|
|
return
|
|
}
|
|
|
|
// Get user
|
|
user, err := h.userRepo.FindByID(c.Request.Context(), userID.(uint))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
return
|
|
}
|
|
|
|
// Update user's default profile
|
|
user.DefaultProfileID = &req.ProfileID
|
|
if err := h.userRepo.Update(c.Request.Context(), user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to set default profile"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Default profile set successfully", "profile_id": req.ProfileID})
|
|
}
|
|
|
|
// UserSettingsResponse represents the user's settings
|
|
type UserSettingsResponse struct {
|
|
AutoTranscriptionEnabled bool `json:"auto_transcription_enabled"`
|
|
DefaultProfileID *string `json:"default_profile_id,omitempty"`
|
|
}
|
|
|
|
// UpdateUserSettingsRequest represents the request to update user settings
|
|
type UpdateUserSettingsRequest struct {
|
|
AutoTranscriptionEnabled *bool `json:"auto_transcription_enabled,omitempty"`
|
|
}
|
|
|
|
// @Summary Get user settings
|
|
// @Description Get the current user's settings including auto-transcription preference
|
|
// @Tags user
|
|
// @Produce json
|
|
// @Success 200 {object} UserSettingsResponse
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/user/settings [get]
|
|
func (h *Handler) GetUserSettings(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
user, err := h.userRepo.FindByID(c.Request.Context(), userID.(uint))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
return
|
|
}
|
|
|
|
response := UserSettingsResponse{
|
|
AutoTranscriptionEnabled: user.AutoTranscriptionEnabled,
|
|
DefaultProfileID: user.DefaultProfileID,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// @Summary Update user settings
|
|
// @Description Update the current user's settings
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body UpdateUserSettingsRequest true "Settings update request"
|
|
// @Success 200 {object} UserSettingsResponse
|
|
// @Failure 400 {object} map[string]string
|
|
// @Failure 401 {object} map[string]string
|
|
// @Failure 500 {object} map[string]string
|
|
// @Security BearerAuth
|
|
// @Router /api/v1/user/settings [put]
|
|
func (h *Handler) UpdateUserSettings(c *gin.Context) {
|
|
userID, exists := c.Get("user_id")
|
|
if !exists {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
|
return
|
|
}
|
|
|
|
var req UpdateUserSettingsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
user, err := h.userRepo.FindByID(c.Request.Context(), userID.(uint))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
|
return
|
|
}
|
|
|
|
// Update fields if provided
|
|
if req.AutoTranscriptionEnabled != nil {
|
|
user.AutoTranscriptionEnabled = *req.AutoTranscriptionEnabled
|
|
}
|
|
|
|
// Save updated user
|
|
if err := h.userRepo.Update(c.Request.Context(), user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
|
|
return
|
|
}
|
|
|
|
response := UserSettingsResponse{
|
|
AutoTranscriptionEnabled: user.AutoTranscriptionEnabled,
|
|
DefaultProfileID: user.DefaultProfileID,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response)
|
|
}
|