mirror of
https://github.com/rishikanthc/Scriberr.git
synced 2026-03-03 02:27:01 +00:00
basic UI
This commit is contained in:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
40
build.sh
Executable file
40
build.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Scriberr Build Script
|
||||
# This script builds the React frontend and embeds it in the Go binary
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
echo "🚀 Starting Scriberr build process..."
|
||||
|
||||
# Step 1: Clean up old files
|
||||
echo "🧹 Cleaning up old build files..."
|
||||
rm -f scriberr
|
||||
rm -rf internal/web/dist
|
||||
cd web/frontend
|
||||
|
||||
# Remove old build output and copied files
|
||||
rm -rf dist/
|
||||
rm -rf assets/ 2>/dev/null || true
|
||||
|
||||
echo "✅ Old files cleaned"
|
||||
|
||||
# Step 2: Build React frontend
|
||||
echo "📦 Building React frontend..."
|
||||
npm run build
|
||||
echo "✅ React frontend built successfully"
|
||||
|
||||
# Step 3: Copy dist files to internal/web for embedding
|
||||
echo "📁 Copying dist files for Go embedding..."
|
||||
cd ../..
|
||||
rm -rf internal/web/dist
|
||||
cp -r web/frontend/dist internal/web/
|
||||
echo "✅ Dist files copied to internal/web"
|
||||
|
||||
# Step 4: Clean Go build cache and rebuild binary
|
||||
echo "🔨 Building Go binary with embedded static files..."
|
||||
go clean -cache
|
||||
go build -o scriberr cmd/server/main.go
|
||||
echo "✅ Go binary built successfully"
|
||||
|
||||
echo "🎉 Build complete! Run './scriberr' to start the server"
|
||||
166
cmd/server/main.go
Normal file
166
cmd/server/main.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/api"
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/config"
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
"scriberr/internal/queue"
|
||||
"scriberr/internal/transcription"
|
||||
|
||||
_ "scriberr/docs" // Import generated docs
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// @title Scriberr API
|
||||
// @version 1.0
|
||||
// @description Audio transcription service using WhisperX
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
// @contact.name API Support
|
||||
// @contact.url http://www.swagger.io/support
|
||||
// @contact.email support@swagger.io
|
||||
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name X-API-Key
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description JWT token with Bearer prefix
|
||||
|
||||
func main() {
|
||||
// Load configuration
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize database
|
||||
if err := database.Initialize(cfg.DatabasePath); err != nil {
|
||||
log.Fatal("Failed to initialize database:", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Initialize authentication service
|
||||
authService := auth.NewAuthService(cfg.JWTSecret)
|
||||
|
||||
// Initialize WhisperX service
|
||||
whisperXService := transcription.NewWhisperXService(cfg)
|
||||
|
||||
// Initialize task queue
|
||||
taskQueue := queue.NewTaskQueue(2, whisperXService) // 2 workers
|
||||
taskQueue.Start()
|
||||
defer taskQueue.Stop()
|
||||
|
||||
// Initialize API handlers
|
||||
handler := api.NewHandler(cfg, authService, taskQueue, whisperXService)
|
||||
|
||||
// Set up router
|
||||
if cfg.Host != "localhost" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
router := api.SetupRoutes(handler, authService)
|
||||
|
||||
// Create server
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Host + ":" + cfg.Port,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
// Initialize default data
|
||||
if err := initializeDefaultData(cfg); err != nil {
|
||||
log.Printf("Warning: Failed to initialize default data: %v", err)
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
log.Printf("Starting server on %s:%s", cfg.Host, cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// Create a deadline for shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Gracefully shutdown the server
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Fatal("Server forced to shutdown:", err)
|
||||
}
|
||||
|
||||
log.Println("Server exited")
|
||||
}
|
||||
|
||||
// initializeDefaultData creates default user and API key for development
|
||||
func initializeDefaultData(cfg *config.Config) error {
|
||||
// Create default user
|
||||
var userCount int64
|
||||
database.DB.Model(&models.User{}).Count(&userCount)
|
||||
|
||||
if userCount == 0 {
|
||||
hashedPassword, err := auth.HashPassword("admin123")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defaultUser := models.User{
|
||||
Username: "admin",
|
||||
Password: hashedPassword,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&defaultUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Created default user: admin/admin123")
|
||||
}
|
||||
|
||||
// Create default API key
|
||||
var apiKeyCount int64
|
||||
database.DB.Model(&models.APIKey{}).Count(&apiKeyCount)
|
||||
|
||||
if apiKeyCount == 0 {
|
||||
defaultAPIKey := models.APIKey{
|
||||
Key: cfg.DefaultAPIKey,
|
||||
Name: "Default Development Key",
|
||||
Description: strPtr("Default API key for development and testing"),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&defaultAPIKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Created default API key: %s", cfg.DefaultAPIKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to create string pointer
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
635
docs/docs.go
Normal file
635
docs/docs.go
Normal file
@@ -0,0 +1,635 @@
|
||||
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import "github.com/swaggo/swag"
|
||||
|
||||
const docTemplate = `{
|
||||
"schemes": {{ marshal .Schemes }},
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "{{escape .Description}}",
|
||||
"title": "{{.Title}}",
|
||||
"termsOfService": "http://swagger.io/terms/",
|
||||
"contact": {
|
||||
"name": "API Support",
|
||||
"url": "http://www.swagger.io/support",
|
||||
"email": "support@swagger.io"
|
||||
},
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
"version": "{{.Version}}"
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/api/v1/admin/queue/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get current queue statistics",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Get queue statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user and return JWT token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User credentials",
|
||||
"name": "credentials",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.LoginResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/list": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of all transcription jobs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "List all transcription records",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Items per page",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by status",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get list of supported WhisperX models",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get supported models",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/submit": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Submit an audio file for transcription with WhisperX",
|
||||
"consumes": [
|
||||
"multipart/form-data"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Submit a transcription job",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "file",
|
||||
"description": "Audio file",
|
||||
"name": "audio",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job title",
|
||||
"name": "title",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Enable speaker diarization",
|
||||
"name": "diarization",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "base",
|
||||
"description": "Whisper model",
|
||||
"name": "model",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Language code",
|
||||
"name": "language",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 16,
|
||||
"description": "Batch size",
|
||||
"name": "batch_size",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "float16",
|
||||
"description": "Compute type",
|
||||
"name": "compute_type",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Device",
|
||||
"name": "device",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Enable VAD filter",
|
||||
"name": "vad_filter",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"default": 0.5,
|
||||
"description": "VAD onset",
|
||||
"name": "vad_onset",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"default": 0.363,
|
||||
"description": "VAD offset",
|
||||
"name": "vad_offset",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Minimum speakers for diarization",
|
||||
"name": "min_speakers",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Maximum speakers for diarization",
|
||||
"name": "max_speakers",
|
||||
"in": "formData"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a specific transcription record by its ID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get transcription record by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}/status": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the current status of a transcription job",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get job status",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}/transcript": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the transcript for a completed transcription job",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get transcript",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Check if the API is healthy",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"health"
|
||||
],
|
||||
"summary": "Health check",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"api.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.LoginResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.JobStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"StatusPending",
|
||||
"StatusProcessing",
|
||||
"StatusCompleted",
|
||||
"StatusFailed"
|
||||
]
|
||||
},
|
||||
"models.TranscriptionJob": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"diarization": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"error_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parameters": {
|
||||
"description": "WhisperX parameters",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.WhisperXParams"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.JobStatus"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.WhisperXParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"batch_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"compute_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_speakers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"min_speakers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"vad_filter": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"vad_offset": {
|
||||
"type": "number"
|
||||
},
|
||||
"vad_onset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"ApiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"name": "X-API-Key",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "JWT token with Bearer prefix",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "1.0",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{},
|
||||
Title: "Scriberr API",
|
||||
Description: "Audio transcription service using WhisperX",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
RightDelim: "}}",
|
||||
}
|
||||
|
||||
func init() {
|
||||
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||
}
|
||||
611
docs/swagger.json
Normal file
611
docs/swagger.json
Normal file
@@ -0,0 +1,611 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "Audio transcription service using WhisperX",
|
||||
"title": "Scriberr API",
|
||||
"termsOfService": "http://swagger.io/terms/",
|
||||
"contact": {
|
||||
"name": "API Support",
|
||||
"url": "http://www.swagger.io/support",
|
||||
"email": "support@swagger.io"
|
||||
},
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://opensource.org/licenses/MIT"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/api/v1/admin/queue/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get current queue statistics",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Get queue statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user and return JWT token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Login",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User credentials",
|
||||
"name": "credentials",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.LoginResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/list": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a list of all transcription jobs",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "List all transcription records",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Items per page",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by status",
|
||||
"name": "status",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get list of supported WhisperX models",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get supported models",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/submit": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Submit an audio file for transcription with WhisperX",
|
||||
"consumes": [
|
||||
"multipart/form-data"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Submit a transcription job",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "file",
|
||||
"description": "Audio file",
|
||||
"name": "audio",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job title",
|
||||
"name": "title",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Enable speaker diarization",
|
||||
"name": "diarization",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "base",
|
||||
"description": "Whisper model",
|
||||
"name": "model",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Language code",
|
||||
"name": "language",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 16,
|
||||
"description": "Batch size",
|
||||
"name": "batch_size",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "float16",
|
||||
"description": "Compute type",
|
||||
"name": "compute_type",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Device",
|
||||
"name": "device",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Enable VAD filter",
|
||||
"name": "vad_filter",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"default": 0.5,
|
||||
"description": "VAD onset",
|
||||
"name": "vad_onset",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "number",
|
||||
"default": 0.363,
|
||||
"description": "VAD offset",
|
||||
"name": "vad_offset",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Minimum speakers for diarization",
|
||||
"name": "min_speakers",
|
||||
"in": "formData"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Maximum speakers for diarization",
|
||||
"name": "max_speakers",
|
||||
"in": "formData"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get a specific transcription record by its ID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get transcription record by ID",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}/status": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the current status of a transcription job",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get job status",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/models.TranscriptionJob"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/transcription/{id}/transcript": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get the transcript for a completed transcription job",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"transcription"
|
||||
],
|
||||
"summary": "Get transcript",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Job ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Check if the API is healthy",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"health"
|
||||
],
|
||||
"summary": "Health check",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"api.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"api.LoginResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.JobStatus": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"processing",
|
||||
"completed",
|
||||
"failed"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"StatusPending",
|
||||
"StatusProcessing",
|
||||
"StatusCompleted",
|
||||
"StatusFailed"
|
||||
]
|
||||
},
|
||||
"models.TranscriptionJob": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"audio_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"diarization": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"error_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"parameters": {
|
||||
"description": "WhisperX parameters",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/models.WhisperXParams"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/models.JobStatus"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"transcript": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models.WhisperXParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"batch_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"compute_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"device": {
|
||||
"type": "string"
|
||||
},
|
||||
"language": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_speakers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"min_speakers": {
|
||||
"type": "integer"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"vad_filter": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"vad_offset": {
|
||||
"type": "number"
|
||||
},
|
||||
"vad_onset": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"ApiKeyAuth": {
|
||||
"type": "apiKey",
|
||||
"name": "X-API-Key",
|
||||
"in": "header"
|
||||
},
|
||||
"BearerAuth": {
|
||||
"description": "JWT token with Bearer prefix",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
400
docs/swagger.yaml
Normal file
400
docs/swagger.yaml
Normal file
@@ -0,0 +1,400 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
api.LoginRequest:
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
api.LoginResponse:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
user:
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
models.JobStatus:
|
||||
enum:
|
||||
- pending
|
||||
- processing
|
||||
- completed
|
||||
- failed
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- StatusPending
|
||||
- StatusProcessing
|
||||
- StatusCompleted
|
||||
- StatusFailed
|
||||
models.TranscriptionJob:
|
||||
properties:
|
||||
audio_path:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
diarization:
|
||||
type: boolean
|
||||
error_message:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
parameters:
|
||||
allOf:
|
||||
- $ref: '#/definitions/models.WhisperXParams'
|
||||
description: WhisperX parameters
|
||||
status:
|
||||
$ref: '#/definitions/models.JobStatus'
|
||||
summary:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
transcript:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
models.WhisperXParams:
|
||||
properties:
|
||||
batch_size:
|
||||
type: integer
|
||||
compute_type:
|
||||
type: string
|
||||
device:
|
||||
type: string
|
||||
language:
|
||||
type: string
|
||||
max_speakers:
|
||||
type: integer
|
||||
min_speakers:
|
||||
type: integer
|
||||
model:
|
||||
type: string
|
||||
vad_filter:
|
||||
type: boolean
|
||||
vad_offset:
|
||||
type: number
|
||||
vad_onset:
|
||||
type: number
|
||||
type: object
|
||||
host: localhost:8080
|
||||
info:
|
||||
contact:
|
||||
email: support@swagger.io
|
||||
name: API Support
|
||||
url: http://www.swagger.io/support
|
||||
description: Audio transcription service using WhisperX
|
||||
license:
|
||||
name: MIT
|
||||
url: https://opensource.org/licenses/MIT
|
||||
termsOfService: http://swagger.io/terms/
|
||||
title: Scriberr API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/v1/admin/queue/stats:
|
||||
get:
|
||||
description: Get current queue statistics
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get queue statistics
|
||||
tags:
|
||||
- admin
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Authenticate user and return JWT token
|
||||
parameters:
|
||||
- description: User credentials
|
||||
in: body
|
||||
name: credentials
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/api.LoginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/api.LoginResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Login
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/transcription/{id}:
|
||||
get:
|
||||
description: Get a specific transcription record by its ID
|
||||
parameters:
|
||||
- description: Job ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.TranscriptionJob'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get transcription record by ID
|
||||
tags:
|
||||
- transcription
|
||||
/api/v1/transcription/{id}/status:
|
||||
get:
|
||||
description: Get the current status of a transcription job
|
||||
parameters:
|
||||
- description: Job ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.TranscriptionJob'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get job status
|
||||
tags:
|
||||
- transcription
|
||||
/api/v1/transcription/{id}/transcript:
|
||||
get:
|
||||
description: Get the transcript for a completed transcription job
|
||||
parameters:
|
||||
- description: Job ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get transcript
|
||||
tags:
|
||||
- transcription
|
||||
/api/v1/transcription/list:
|
||||
get:
|
||||
description: Get a list of all transcription jobs
|
||||
parameters:
|
||||
- default: 1
|
||||
description: Page number
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- default: 10
|
||||
description: Items per page
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Filter by status
|
||||
in: query
|
||||
name: status
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List all transcription records
|
||||
tags:
|
||||
- transcription
|
||||
/api/v1/transcription/models:
|
||||
get:
|
||||
description: Get list of supported WhisperX models
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties: true
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get supported models
|
||||
tags:
|
||||
- transcription
|
||||
/api/v1/transcription/submit:
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
description: Submit an audio file for transcription with WhisperX
|
||||
parameters:
|
||||
- description: Audio file
|
||||
in: formData
|
||||
name: audio
|
||||
required: true
|
||||
type: file
|
||||
- description: Job title
|
||||
in: formData
|
||||
name: title
|
||||
type: string
|
||||
- description: Enable speaker diarization
|
||||
in: formData
|
||||
name: diarization
|
||||
type: boolean
|
||||
- default: base
|
||||
description: Whisper model
|
||||
in: formData
|
||||
name: model
|
||||
type: string
|
||||
- description: Language code
|
||||
in: formData
|
||||
name: language
|
||||
type: string
|
||||
- default: 16
|
||||
description: Batch size
|
||||
in: formData
|
||||
name: batch_size
|
||||
type: integer
|
||||
- default: float16
|
||||
description: Compute type
|
||||
in: formData
|
||||
name: compute_type
|
||||
type: string
|
||||
- default: auto
|
||||
description: Device
|
||||
in: formData
|
||||
name: device
|
||||
type: string
|
||||
- description: Enable VAD filter
|
||||
in: formData
|
||||
name: vad_filter
|
||||
type: boolean
|
||||
- default: 0.5
|
||||
description: VAD onset
|
||||
in: formData
|
||||
name: vad_onset
|
||||
type: number
|
||||
- default: 0.363
|
||||
description: VAD offset
|
||||
in: formData
|
||||
name: vad_offset
|
||||
type: number
|
||||
- description: Minimum speakers for diarization
|
||||
in: formData
|
||||
name: min_speakers
|
||||
type: integer
|
||||
- description: Maximum speakers for diarization
|
||||
in: formData
|
||||
name: max_speakers
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/models.TranscriptionJob'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Submit a transcription job
|
||||
tags:
|
||||
- transcription
|
||||
/health:
|
||||
get:
|
||||
description: Check if the API is healthy
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Health check
|
||||
tags:
|
||||
- health
|
||||
securityDefinitions:
|
||||
ApiKeyAuth:
|
||||
in: header
|
||||
name: X-API-Key
|
||||
type: apiKey
|
||||
BearerAuth:
|
||||
description: JWT token with Bearer prefix
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
swagger: "2.0"
|
||||
69
go.mod
Normal file
69
go.mod
Normal file
@@ -0,0 +1,69 @@
|
||||
module scriberr
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.0
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.32.0
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
200
go.sum
Normal file
200
go.sum
Normal file
@@ -0,0 +1,200 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
|
||||
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
447
internal/api/handlers.go
Normal file
447
internal/api/handlers.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/config"
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
"scriberr/internal/queue"
|
||||
"scriberr/internal/transcription"
|
||||
|
||||
"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
|
||||
taskQueue *queue.TaskQueue
|
||||
whisperXService *transcription.WhisperXService
|
||||
}
|
||||
|
||||
// NewHandler creates a new handler
|
||||
func NewHandler(cfg *config.Config, authService *auth.AuthService, taskQueue *queue.TaskQueue, whisperXService *transcription.WhisperXService) *Handler {
|
||||
return &Handler{
|
||||
config: cfg,
|
||||
authService: authService,
|
||||
taskQueue: taskQueue,
|
||||
whisperXService: whisperXService,
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// @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
|
||||
func (h *Handler) SubmitJob(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()
|
||||
|
||||
// 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 filename
|
||||
jobID := uuid.New().String()
|
||||
ext := filepath.Ext(header.Filename)
|
||||
filename := fmt.Sprintf("%s%s", jobID, ext)
|
||||
filePath := filepath.Join(uploadDir, filename)
|
||||
|
||||
// Save file
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
if _, err = io.Copy(dst, file); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse parameters
|
||||
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"),
|
||||
VadFilter: getFormBoolWithDefault(c, "vad_filter", false),
|
||||
VadOnset: getFormFloatWithDefault(c, "vad_onset", 0.500),
|
||||
VadOffset: getFormFloatWithDefault(c, "vad_offset", 0.363),
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Create job
|
||||
job := models.TranscriptionJob{
|
||||
ID: jobID,
|
||||
AudioPath: filePath,
|
||||
Status: models.StatusPending,
|
||||
Diarization: getFormBoolWithDefault(c, "diarization", false),
|
||||
Parameters: params,
|
||||
}
|
||||
|
||||
if title := c.PostForm("title"); title != "" {
|
||||
job.Title = &title
|
||||
}
|
||||
|
||||
// Save to database
|
||||
if err := database.DB.Create(&job).Error; err != nil {
|
||||
os.Remove(filePath) // Clean up file
|
||||
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
|
||||
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
|
||||
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
|
||||
// @Tags transcription
|
||||
// @Produce json
|
||||
// @Param page query int false "Page number" default(1)
|
||||
// @Param limit query int false "Items per page" default(10)
|
||||
// @Param status query string false "Filter by status"
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/v1/transcription/list [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *Handler) ListJobs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
status := c.Query("status")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
offset := (page - 1) * limit
|
||||
|
||||
query := database.DB.Model(&models.TranscriptionJob{})
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
var jobs []models.TranscriptionJob
|
||||
var total int64
|
||||
|
||||
query.Count(&total)
|
||||
|
||||
if err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&jobs).Error; 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 record by ID
|
||||
// @Description Get a specific transcription record by its ID
|
||||
// @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
|
||||
func (h *Handler) GetJobByID(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
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, job)
|
||||
}
|
||||
|
||||
// @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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
if !auth.CheckPassword(req.Password, user.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
|
||||
}
|
||||
|
||||
response := LoginResponse{
|
||||
Token: token,
|
||||
}
|
||||
response.User.ID = user.ID
|
||||
response.User.Username = user.Username
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// @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
|
||||
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
|
||||
func (h *Handler) GetSupportedModels(c *gin.Context) {
|
||||
models := h.whisperXService.GetSupportedModels()
|
||||
languages := h.whisperXService.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
|
||||
}
|
||||
74
internal/api/router.go
Normal file
74
internal/api/router.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/web"
|
||||
"scriberr/pkg/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// SetupRoutes sets up all API routes
|
||||
func SetupRoutes(handler *Handler, authService *auth.AuthService) *gin.Engine {
|
||||
// Create Gin router
|
||||
router := gin.Default()
|
||||
|
||||
// Add CORS middleware
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-API-Key")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
router.GET("/health", handler.HealthCheck)
|
||||
|
||||
// Swagger documentation
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// API v1 routes
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Authentication routes (no auth required)
|
||||
auth := v1.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", handler.Login)
|
||||
}
|
||||
|
||||
// Transcription routes (require authentication)
|
||||
transcription := v1.Group("/transcription")
|
||||
transcription.Use(middleware.AuthMiddleware(authService))
|
||||
{
|
||||
transcription.POST("/submit", handler.SubmitJob)
|
||||
transcription.GET("/:id/status", handler.GetJobStatus)
|
||||
transcription.GET("/:id/transcript", handler.GetTranscript)
|
||||
transcription.GET("/:id", handler.GetJobByID)
|
||||
transcription.GET("/list", handler.ListJobs)
|
||||
transcription.GET("/models", handler.GetSupportedModels)
|
||||
}
|
||||
|
||||
// Admin routes (require authentication)
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(middleware.AuthMiddleware(authService))
|
||||
{
|
||||
queue := admin.Group("/queue")
|
||||
{
|
||||
queue.GET("/stats", handler.GetQueueStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up static file serving for React app
|
||||
web.SetupStaticRoutes(router)
|
||||
|
||||
return router
|
||||
}
|
||||
74
internal/auth/auth.go
Normal file
74
internal/auth/auth.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/models"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// AuthService handles authentication operations
|
||||
type AuthService struct {
|
||||
jwtSecret []byte
|
||||
}
|
||||
|
||||
// NewAuthService creates a new authentication service
|
||||
func NewAuthService(jwtSecret string) *AuthService {
|
||||
return &AuthService{
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
}
|
||||
}
|
||||
|
||||
// Claims represents JWT claims
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken generates a JWT token for a user
|
||||
func (as *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(as.jwtSecret)
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT token and returns claims
|
||||
func (as *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return as.jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword checks if a password matches its hash
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
81
internal/config/config.go
Normal file
81
internal/config/config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config holds all configuration values
|
||||
type Config struct {
|
||||
// Server configuration
|
||||
Port string
|
||||
Host string
|
||||
|
||||
// Database configuration
|
||||
DatabasePath string
|
||||
|
||||
// JWT configuration
|
||||
JWTSecret string
|
||||
|
||||
// File storage
|
||||
UploadDir string
|
||||
|
||||
// Python/WhisperX configuration
|
||||
PythonPath string
|
||||
UVPath string
|
||||
WhisperXEnv string
|
||||
|
||||
// Default API key for testing
|
||||
DefaultAPIKey string
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables and .env file
|
||||
func Load() *Config {
|
||||
// Load .env file if it exists
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Println("No .env file found, using system environment variables")
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
Host: getEnv("HOST", "localhost"),
|
||||
DatabasePath: getEnv("DATABASE_PATH", "data/scriberr.db"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"),
|
||||
UploadDir: getEnv("UPLOAD_DIR", "data/uploads"),
|
||||
PythonPath: getEnv("PYTHON_PATH", "python3"),
|
||||
UVPath: getEnv("UV_PATH", "uv"),
|
||||
WhisperXEnv: getEnv("WHISPERX_ENV", "data/whisperx-env"),
|
||||
DefaultAPIKey: getEnv("DEFAULT_API_KEY", "dev-api-key-123"),
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable with a default value
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsInt gets an environment variable as int with a default value
|
||||
func getEnvAsInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if intValue, err := strconv.Atoi(value); err == nil {
|
||||
return intValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// getEnvAsBool gets an environment variable as bool with a default value
|
||||
func getEnvAsBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if boolValue, err := strconv.ParseBool(value); err == nil {
|
||||
return boolValue
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
53
internal/database/database.go
Normal file
53
internal/database/database.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"scriberr/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// DB is the global database instance
|
||||
var DB *gorm.DB
|
||||
|
||||
// Initialize initializes the database connection
|
||||
func Initialize(dbPath string) error {
|
||||
var err error
|
||||
|
||||
// Create database directory if it doesn't exist
|
||||
if err := os.MkdirAll("data", 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %v", err)
|
||||
}
|
||||
|
||||
// Open database connection
|
||||
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Auto migrate the schema
|
||||
if err := DB.AutoMigrate(
|
||||
&models.TranscriptionJob{},
|
||||
&models.User{},
|
||||
&models.APIKey{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to auto migrate: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func Close() error {
|
||||
sqlDB, err := DB.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
85
internal/models/transcription.go
Normal file
85
internal/models/transcription.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TranscriptionJob represents a transcription job record
|
||||
type TranscriptionJob struct {
|
||||
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
|
||||
Title *string `json:"title,omitempty" gorm:"type:text"`
|
||||
Status JobStatus `json:"status" gorm:"type:varchar(20);not null;default:'pending'"`
|
||||
AudioPath string `json:"audio_path" gorm:"type:text;not null"`
|
||||
Transcript *string `json:"transcript,omitempty" gorm:"type:text"`
|
||||
Diarization bool `json:"diarization" gorm:"type:boolean;default:false"`
|
||||
Summary *string `json:"summary,omitempty" gorm:"type:text"`
|
||||
ErrorMessage *string `json:"error_message,omitempty" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
|
||||
// WhisperX parameters
|
||||
Parameters WhisperXParams `json:"parameters" gorm:"embedded"`
|
||||
}
|
||||
|
||||
// JobStatus represents the status of a transcription job
|
||||
type JobStatus string
|
||||
|
||||
const (
|
||||
StatusPending JobStatus = "pending"
|
||||
StatusProcessing JobStatus = "processing"
|
||||
StatusCompleted JobStatus = "completed"
|
||||
StatusFailed JobStatus = "failed"
|
||||
)
|
||||
|
||||
// WhisperXParams contains parameters for WhisperX transcription
|
||||
type WhisperXParams struct {
|
||||
Model string `json:"model" gorm:"type:varchar(50);default:'base'"`
|
||||
Language *string `json:"language,omitempty" gorm:"type:varchar(10)"`
|
||||
BatchSize int `json:"batch_size" gorm:"type:int;default:16"`
|
||||
ComputeType string `json:"compute_type" gorm:"type:varchar(20);default:'int8'"`
|
||||
Device string `json:"device" gorm:"type:varchar(20);default:'cpu'"`
|
||||
VadFilter bool `json:"vad_filter" gorm:"type:boolean;default:false"`
|
||||
VadOnset float64 `json:"vad_onset" gorm:"type:real;default:0.500"`
|
||||
VadOffset float64 `json:"vad_offset" gorm:"type:real;default:0.363"`
|
||||
MinSpeakers *int `json:"min_speakers,omitempty" gorm:"type:int"`
|
||||
MaxSpeakers *int `json:"max_speakers,omitempty" gorm:"type:int"`
|
||||
}
|
||||
|
||||
// BeforeCreate sets the ID if not already set
|
||||
func (tj *TranscriptionJob) BeforeCreate(tx *gorm.DB) error {
|
||||
if tj.ID == "" {
|
||||
tj.ID = uuid.New().String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// User represents a user for authentication
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"uniqueIndex;not null;type:varchar(50)"`
|
||||
Password string `json:"-" gorm:"not null;type:varchar(255)"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// APIKey represents an API key for external authentication
|
||||
type APIKey struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Key string `json:"key" gorm:"uniqueIndex;not null;type:varchar(255)"`
|
||||
Name string `json:"name" gorm:"not null;type:varchar(100)"`
|
||||
Description *string `json:"description,omitempty" gorm:"type:text"`
|
||||
IsActive bool `json:"is_active" gorm:"type:boolean;default:true"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
// BeforeCreate sets the API key if not already set
|
||||
func (ak *APIKey) BeforeCreate(tx *gorm.DB) error {
|
||||
if ak.Key == "" {
|
||||
ak.Key = uuid.New().String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
198
internal/queue/queue.go
Normal file
198
internal/queue/queue.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
)
|
||||
|
||||
// TaskQueue manages transcription job processing
|
||||
type TaskQueue struct {
|
||||
workers int
|
||||
jobChannel chan string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
processor JobProcessor
|
||||
}
|
||||
|
||||
// JobProcessor defines the interface for processing jobs
|
||||
type JobProcessor interface {
|
||||
ProcessJob(jobID string) error
|
||||
}
|
||||
|
||||
// NewTaskQueue creates a new task queue
|
||||
func NewTaskQueue(workers int, processor JobProcessor) *TaskQueue {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &TaskQueue{
|
||||
workers: workers,
|
||||
jobChannel: make(chan string, 100), // Buffer for 100 jobs
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the task queue workers
|
||||
func (tq *TaskQueue) Start() {
|
||||
log.Printf("Starting task queue with %d workers", tq.workers)
|
||||
|
||||
for i := 0; i < tq.workers; i++ {
|
||||
tq.wg.Add(1)
|
||||
go tq.worker(i)
|
||||
}
|
||||
|
||||
// Start the job scanner
|
||||
tq.wg.Add(1)
|
||||
go tq.jobScanner()
|
||||
}
|
||||
|
||||
// Stop stops the task queue
|
||||
func (tq *TaskQueue) Stop() {
|
||||
log.Println("Stopping task queue...")
|
||||
tq.cancel()
|
||||
close(tq.jobChannel)
|
||||
tq.wg.Wait()
|
||||
log.Println("Task queue stopped")
|
||||
}
|
||||
|
||||
// EnqueueJob adds a job to the queue
|
||||
func (tq *TaskQueue) EnqueueJob(jobID string) error {
|
||||
select {
|
||||
case tq.jobChannel <- jobID:
|
||||
return nil
|
||||
case <-tq.ctx.Done():
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
default:
|
||||
return fmt.Errorf("queue is full")
|
||||
}
|
||||
}
|
||||
|
||||
// worker processes jobs from the channel
|
||||
func (tq *TaskQueue) worker(id int) {
|
||||
defer tq.wg.Done()
|
||||
|
||||
log.Printf("Worker %d started", id)
|
||||
|
||||
for {
|
||||
select {
|
||||
case jobID, ok := <-tq.jobChannel:
|
||||
if !ok {
|
||||
log.Printf("Worker %d stopped", id)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Worker %d processing job %s", id, jobID)
|
||||
|
||||
// Update job status to processing
|
||||
if err := tq.updateJobStatus(jobID, models.StatusProcessing); err != nil {
|
||||
log.Printf("Worker %d: Failed to update job %s status to processing: %v", id, jobID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the job
|
||||
if err := tq.processor.ProcessJob(jobID); err != nil {
|
||||
log.Printf("Worker %d: Failed to process job %s: %v", id, jobID, err)
|
||||
tq.updateJobStatus(jobID, models.StatusFailed)
|
||||
tq.updateJobError(jobID, err.Error())
|
||||
} else {
|
||||
log.Printf("Worker %d: Successfully processed job %s", id, jobID)
|
||||
tq.updateJobStatus(jobID, models.StatusCompleted)
|
||||
}
|
||||
|
||||
case <-tq.ctx.Done():
|
||||
log.Printf("Worker %d stopped due to context cancellation", id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jobScanner scans for pending jobs and adds them to the queue
|
||||
func (tq *TaskQueue) jobScanner() {
|
||||
defer tq.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second) // Scan every 10 seconds
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("Job scanner started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
tq.scanPendingJobs()
|
||||
case <-tq.ctx.Done():
|
||||
log.Println("Job scanner stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanPendingJobs finds pending jobs and enqueues them
|
||||
func (tq *TaskQueue) scanPendingJobs() {
|
||||
var jobs []models.TranscriptionJob
|
||||
|
||||
if err := database.DB.Where("status = ?", models.StatusPending).Find(&jobs).Error; err != nil {
|
||||
log.Printf("Failed to scan pending jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
select {
|
||||
case tq.jobChannel <- job.ID:
|
||||
log.Printf("Enqueued pending job %s", job.ID)
|
||||
default:
|
||||
log.Printf("Queue is full, skipping job %s", job.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateJobStatus updates the status of a job
|
||||
func (tq *TaskQueue) updateJobStatus(jobID string, status models.JobStatus) error {
|
||||
return database.DB.Model(&models.TranscriptionJob{}).
|
||||
Where("id = ?", jobID).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// updateJobError updates the error message of a job
|
||||
func (tq *TaskQueue) updateJobError(jobID string, errorMsg string) error {
|
||||
return database.DB.Model(&models.TranscriptionJob{}).
|
||||
Where("id = ?", jobID).
|
||||
Update("error_message", errorMsg).Error
|
||||
}
|
||||
|
||||
// GetJobStatus gets the status of a job
|
||||
func (tq *TaskQueue) GetJobStatus(jobID string) (*models.TranscriptionJob, error) {
|
||||
var job models.TranscriptionJob
|
||||
err := database.DB.Where("id = ?", jobID).First(&job).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &job, nil
|
||||
}
|
||||
|
||||
// GetQueueStats returns queue statistics
|
||||
func (tq *TaskQueue) GetQueueStats() map[string]interface{} {
|
||||
var pendingCount, processingCount, completedCount, failedCount int64
|
||||
|
||||
database.DB.Model(&models.TranscriptionJob{}).Where("status = ?", models.StatusPending).Count(&pendingCount)
|
||||
database.DB.Model(&models.TranscriptionJob{}).Where("status = ?", models.StatusProcessing).Count(&processingCount)
|
||||
database.DB.Model(&models.TranscriptionJob{}).Where("status = ?", models.StatusCompleted).Count(&completedCount)
|
||||
database.DB.Model(&models.TranscriptionJob{}).Where("status = ?", models.StatusFailed).Count(&failedCount)
|
||||
|
||||
return map[string]interface{}{
|
||||
"queue_size": len(tq.jobChannel),
|
||||
"queue_capacity": cap(tq.jobChannel),
|
||||
"workers": tq.workers,
|
||||
"pending_jobs": pendingCount,
|
||||
"processing_jobs": processingCount,
|
||||
"completed_jobs": completedCount,
|
||||
"failed_jobs": failedCount,
|
||||
}
|
||||
}
|
||||
255
internal/transcription/whisperx.go
Normal file
255
internal/transcription/whisperx.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package transcription
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"scriberr/internal/config"
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
)
|
||||
|
||||
// WhisperXService handles WhisperX transcription
|
||||
type WhisperXService struct {
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewWhisperXService creates a new WhisperX service
|
||||
func NewWhisperXService(cfg *config.Config) *WhisperXService {
|
||||
return &WhisperXService{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// TranscriptResult represents the WhisperX output format
|
||||
type TranscriptResult struct {
|
||||
Segments []Segment `json:"segments"`
|
||||
Word []Word `json:"word_segments,omitempty"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// Segment represents a transcript segment
|
||||
type Segment struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Text string `json:"text"`
|
||||
Speaker *string `json:"speaker,omitempty"`
|
||||
}
|
||||
|
||||
// Word represents a word-level transcript
|
||||
type Word struct {
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
Word string `json:"word"`
|
||||
Score float64 `json:"score"`
|
||||
Speaker *string `json:"speaker,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessJob implements the JobProcessor interface
|
||||
func (ws *WhisperXService) ProcessJob(jobID string) error {
|
||||
// Get the job from database
|
||||
var job models.TranscriptionJob
|
||||
if err := database.DB.Where("id = ?", jobID).First(&job).Error; err != nil {
|
||||
return fmt.Errorf("failed to get job: %v", err)
|
||||
}
|
||||
|
||||
// Ensure Python environment is set up
|
||||
if err := ws.ensurePythonEnv(); err != nil {
|
||||
return fmt.Errorf("failed to setup Python environment: %v", err)
|
||||
}
|
||||
|
||||
// Check if audio file exists
|
||||
if _, err := os.Stat(job.AudioPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("audio file not found: %s", job.AudioPath)
|
||||
}
|
||||
|
||||
// Prepare output directory
|
||||
outputDir := filepath.Join("data", "transcripts", jobID)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
// Build WhisperX command
|
||||
cmd, err := ws.buildWhisperXCommand(&job, outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build command: %v", err)
|
||||
}
|
||||
|
||||
// Execute WhisperX
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("WhisperX execution failed: %v", err)
|
||||
}
|
||||
|
||||
// Load and parse the result
|
||||
resultPath := filepath.Join(outputDir, "result.json")
|
||||
if err := ws.parseAndSaveResult(jobID, resultPath); err != nil {
|
||||
return fmt.Errorf("failed to parse result: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensurePythonEnv ensures the Python environment is set up
|
||||
func (ws *WhisperXService) ensurePythonEnv() error {
|
||||
envPath := ws.config.WhisperXEnv
|
||||
|
||||
// Check if environment exists
|
||||
if _, err := os.Stat(envPath); os.IsNotExist(err) {
|
||||
return ws.createPythonEnv()
|
||||
}
|
||||
|
||||
// Check if WhisperX is installed
|
||||
cmd := exec.Command(ws.config.UVPath, "run", "--project", envPath, "python", "-c", "import whisperx")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return ws.installWhisperX()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createPythonEnv creates a new Python environment
|
||||
func (ws *WhisperXService) createPythonEnv() error {
|
||||
envPath := ws.config.WhisperXEnv
|
||||
|
||||
// Create directory
|
||||
if err := os.MkdirAll(envPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create environment directory: %v", err)
|
||||
}
|
||||
|
||||
// Initialize uv project
|
||||
cmd := exec.Command(ws.config.UVPath, "init", "--python", "3.9", envPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to initialize uv project: %v", err)
|
||||
}
|
||||
|
||||
return ws.installWhisperX()
|
||||
}
|
||||
|
||||
// installWhisperX installs WhisperX and dependencies
|
||||
func (ws *WhisperXService) installWhisperX() error {
|
||||
envPath := ws.config.WhisperXEnv
|
||||
|
||||
// Install WhisperX
|
||||
cmd := exec.Command(ws.config.UVPath, "add", "--project", envPath,
|
||||
"git+https://github.com/m-bain/whisperX.git",
|
||||
"torch", "torchaudio", "numpy", "pandas")
|
||||
cmd.Dir = envPath
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to install WhisperX: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildWhisperXCommand builds the WhisperX command
|
||||
func (ws *WhisperXService) buildWhisperXCommand(job *models.TranscriptionJob, outputDir string) (*exec.Cmd, error) {
|
||||
args := []string{
|
||||
"run", "--project", ws.config.WhisperXEnv, "python", "-m", "whisperx",
|
||||
job.AudioPath,
|
||||
"--output_dir", outputDir,
|
||||
"--output_format", "json",
|
||||
"--model", job.Parameters.Model,
|
||||
"--batch_size", strconv.Itoa(job.Parameters.BatchSize),
|
||||
"--compute_type", job.Parameters.ComputeType,
|
||||
"--device", job.Parameters.Device,
|
||||
}
|
||||
|
||||
if job.Parameters.Language != nil {
|
||||
args = append(args, "--language", *job.Parameters.Language)
|
||||
}
|
||||
|
||||
if job.Parameters.VadFilter {
|
||||
args = append(args, "--vad_filter")
|
||||
args = append(args, "--vad_onset", fmt.Sprintf("%.3f", job.Parameters.VadOnset))
|
||||
args = append(args, "--vad_offset", fmt.Sprintf("%.3f", job.Parameters.VadOffset))
|
||||
}
|
||||
|
||||
if job.Diarization {
|
||||
args = append(args, "--diarize")
|
||||
if job.Parameters.MinSpeakers != nil {
|
||||
args = append(args, "--min_speakers", strconv.Itoa(*job.Parameters.MinSpeakers))
|
||||
}
|
||||
if job.Parameters.MaxSpeakers != nil {
|
||||
args = append(args, "--max_speakers", strconv.Itoa(*job.Parameters.MaxSpeakers))
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(ws.config.UVPath, args...)
|
||||
cmd.Env = append(os.Environ(), "PYTHONUNBUFFERED=1")
|
||||
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// parseAndSaveResult parses WhisperX output and saves to database
|
||||
func (ws *WhisperXService) parseAndSaveResult(jobID, resultPath string) error {
|
||||
// Find the actual result file (WhisperX creates files based on input filename)
|
||||
files, err := filepath.Glob(filepath.Join(filepath.Dir(resultPath), "*.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find result files: %v", err)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no result files found")
|
||||
}
|
||||
|
||||
// Use the first JSON file found
|
||||
resultFile := files[0]
|
||||
|
||||
// Read the result file
|
||||
data, err := os.ReadFile(resultFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read result file: %v", err)
|
||||
}
|
||||
|
||||
// Parse the result
|
||||
var result TranscriptResult
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse JSON result: %v", err)
|
||||
}
|
||||
|
||||
// Convert to JSON string for database storage
|
||||
transcriptJSON, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal transcript: %v", err)
|
||||
}
|
||||
transcriptStr := string(transcriptJSON)
|
||||
|
||||
// Update the job in the database
|
||||
if err := database.DB.Model(&models.TranscriptionJob{}).
|
||||
Where("id = ?", jobID).
|
||||
Update("transcript", &transcriptStr).Error; err != nil {
|
||||
return fmt.Errorf("failed to update job transcript: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSupportedModels returns a list of supported WhisperX models
|
||||
func (ws *WhisperXService) GetSupportedModels() []string {
|
||||
return []string{
|
||||
"tiny", "tiny.en",
|
||||
"base", "base.en",
|
||||
"small", "small.en",
|
||||
"medium", "medium.en",
|
||||
"large", "large-v1", "large-v2", "large-v3",
|
||||
}
|
||||
}
|
||||
|
||||
// GetSupportedLanguages returns a list of supported languages
|
||||
func (ws *WhisperXService) GetSupportedLanguages() []string {
|
||||
return []string{
|
||||
"en", "zh", "de", "es", "ru", "ko", "fr", "ja", "pt", "tr", "pl", "ca", "nl",
|
||||
"ar", "sv", "it", "id", "hi", "fi", "vi", "he", "uk", "el", "ms", "cs", "ro",
|
||||
"da", "hu", "ta", "no", "th", "ur", "hr", "bg", "lt", "la", "mi", "ml", "cy",
|
||||
"sk", "te", "fa", "lv", "bn", "sr", "az", "sl", "kn", "et", "mk", "br", "eu",
|
||||
"is", "hy", "ne", "mn", "bs", "kk", "sq", "sw", "gl", "mr", "pa", "si", "km",
|
||||
"sn", "yo", "so", "af", "oc", "ka", "be", "tg", "sd", "gu", "am", "yi", "lo",
|
||||
"uz", "fo", "ht", "ps", "tk", "nn", "mt", "sa", "lb", "my", "bo", "tl", "mg",
|
||||
"as", "tt", "haw", "ln", "ha", "ba", "jw", "su",
|
||||
}
|
||||
}
|
||||
1
internal/web/dist/assets/index-BGvUZLTp.css
vendored
Normal file
1
internal/web/dist/assets/index-BGvUZLTp.css
vendored
Normal file
File diff suppressed because one or more lines are too long
89
internal/web/dist/assets/index-DYgV1ItK.js
vendored
Normal file
89
internal/web/dist/assets/index-DYgV1ItK.js
vendored
Normal file
File diff suppressed because one or more lines are too long
17
internal/web/dist/index.html
vendored
Normal file
17
internal/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scriberr - Audio Transcription</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-DYgV1ItK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BGvUZLTp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
internal/web/dist/vite.svg
vendored
Normal file
1
internal/web/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
internal/web/frontend/assets/index-Czw5UNl_.css
Normal file
1
internal/web/frontend/assets/index-Czw5UNl_.css
Normal file
File diff suppressed because one or more lines are too long
49
internal/web/frontend/assets/index-Dz2_a66b.js
Normal file
49
internal/web/frontend/assets/index-Dz2_a66b.js
Normal file
File diff suppressed because one or more lines are too long
14
internal/web/frontend/index.html
Normal file
14
internal/web/frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scriberr - Audio Transcription</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dz2_a66b.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Czw5UNl_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
internal/web/frontend/vite.svg
Normal file
1
internal/web/frontend/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
88
internal/web/static.go
Normal file
88
internal/web/static.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
// GetStaticHandler returns a handler for serving embedded static files
|
||||
func GetStaticHandler() http.Handler {
|
||||
// Get the dist subdirectory from embedded files
|
||||
distFS, err := fs.Sub(staticFiles, "dist")
|
||||
if err != nil {
|
||||
panic("failed to get dist subdirectory: " + err.Error())
|
||||
}
|
||||
|
||||
return http.FileServer(http.FS(distFS))
|
||||
}
|
||||
|
||||
// GetIndexHTML returns the index.html content
|
||||
func GetIndexHTML() ([]byte, error) {
|
||||
return staticFiles.ReadFile("dist/index.html")
|
||||
}
|
||||
|
||||
// SetupStaticRoutes configures static file serving in Gin
|
||||
func SetupStaticRoutes(router *gin.Engine) {
|
||||
|
||||
// Serve static assets (CSS, JS, images) directly from embedded filesystem
|
||||
router.GET("/assets/*filepath", func(c *gin.Context) {
|
||||
// Extract the file path
|
||||
filepath := c.Param("filepath")
|
||||
// Remove leading slash if present
|
||||
if filepath[0] == '/' {
|
||||
filepath = filepath[1:]
|
||||
}
|
||||
fullPath := "assets/" + filepath
|
||||
|
||||
// Try to read the file from embedded filesystem
|
||||
fileContent, err := staticFiles.ReadFile("dist/" + fullPath)
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set appropriate content type based on file extension
|
||||
if strings.Contains(fullPath, ".css") {
|
||||
c.Data(http.StatusOK, "text/css", fileContent)
|
||||
} else if strings.Contains(fullPath, ".js") {
|
||||
c.Data(http.StatusOK, "application/javascript", fileContent)
|
||||
} else {
|
||||
c.Data(http.StatusOK, "application/octet-stream", fileContent)
|
||||
}
|
||||
})
|
||||
|
||||
// Serve vite.svg
|
||||
router.GET("/vite.svg", func(c *gin.Context) {
|
||||
fileContent, err := staticFiles.ReadFile("dist/vite.svg")
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "image/svg+xml", fileContent)
|
||||
})
|
||||
|
||||
// Serve index.html for root and any unmatched routes (SPA behavior)
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
// For API routes, return 404
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api") {
|
||||
c.JSON(404, gin.H{"error": "API endpoint not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// For all other routes, serve the React app
|
||||
indexHTML, err := GetIndexHTML()
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "Error loading page")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML)
|
||||
})
|
||||
}
|
||||
118
pkg/middleware/auth.go
Normal file
118
pkg/middleware/auth.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware handles both API key and JWT authentication
|
||||
func AuthMiddleware(authService *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Check for API key first
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey != "" {
|
||||
if validateAPIKey(apiKey) {
|
||||
c.Set("auth_type", "api_key")
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for JWT token
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authentication"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
claims, err := authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("auth_type", "jwt")
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// validateAPIKey validates an API key against the database
|
||||
func validateAPIKey(key string) bool {
|
||||
var apiKey models.APIKey
|
||||
result := database.DB.Where("key = ? AND is_active = ?", key, true).First(&apiKey)
|
||||
return result.Error == nil
|
||||
}
|
||||
|
||||
// APIKeyOnlyMiddleware only allows API key authentication
|
||||
func APIKeyOnlyMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("X-API-Key")
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !validateAPIKey(apiKey) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("auth_type", "api_key")
|
||||
c.Set("api_key", apiKey)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// JWTOnlyMiddleware only allows JWT authentication
|
||||
func JWTOnlyMiddleware(authService *auth.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
token := parts[1]
|
||||
claims, err := authService.ValidateToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("auth_type", "jwt")
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("username", claims.Username)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
450
tests/api_test.go
Normal file
450
tests/api_test.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/api"
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/config"
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
"scriberr/internal/queue"
|
||||
"scriberr/internal/transcription"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type APITestSuite struct {
|
||||
suite.Suite
|
||||
router *gin.Engine
|
||||
config *config.Config
|
||||
authService *auth.AuthService
|
||||
taskQueue *queue.TaskQueue
|
||||
whisperXService *transcription.WhisperXService
|
||||
handler *api.Handler
|
||||
apiKey string
|
||||
token string
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) SetupSuite() {
|
||||
// Set Gin to test mode
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create test configuration
|
||||
suite.config = &config.Config{
|
||||
Port: "8080",
|
||||
Host: "localhost",
|
||||
DatabasePath: "test.db",
|
||||
JWTSecret: "test-secret",
|
||||
UploadDir: "test_uploads",
|
||||
PythonPath: "python3",
|
||||
UVPath: "uv",
|
||||
WhisperXEnv: "test_whisperx_env",
|
||||
DefaultAPIKey: "test-api-key",
|
||||
}
|
||||
|
||||
// Initialize test database
|
||||
if err := database.Initialize(suite.config.DatabasePath); err != nil {
|
||||
suite.T().Fatal("Failed to initialize test database:", err)
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
suite.authService = auth.NewAuthService(suite.config.JWTSecret)
|
||||
suite.whisperXService = transcription.NewWhisperXService(suite.config)
|
||||
suite.taskQueue = queue.NewTaskQueue(1, suite.whisperXService)
|
||||
suite.handler = api.NewHandler(suite.config, suite.authService, suite.taskQueue, suite.whisperXService)
|
||||
|
||||
// Set up router
|
||||
suite.router = api.SetupRoutes(suite.handler, suite.authService)
|
||||
|
||||
// Create test data
|
||||
suite.setupTestData()
|
||||
|
||||
// Create upload directory
|
||||
os.MkdirAll(suite.config.UploadDir, 0755)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TearDownSuite() {
|
||||
// Clean up test database
|
||||
database.Close()
|
||||
os.Remove(suite.config.DatabasePath)
|
||||
|
||||
// Clean up upload directory
|
||||
os.RemoveAll(suite.config.UploadDir)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) setupTestData() {
|
||||
// Create test user
|
||||
hashedPassword, _ := auth.HashPassword("testpass")
|
||||
user := models.User{
|
||||
Username: "testuser",
|
||||
Password: hashedPassword,
|
||||
}
|
||||
database.DB.Create(&user)
|
||||
|
||||
// Generate JWT token
|
||||
token, _ := suite.authService.GenerateToken(&user)
|
||||
suite.token = token
|
||||
|
||||
// Create test API key
|
||||
apiKey := models.APIKey{
|
||||
Key: "test-api-key-123",
|
||||
Name: "Test API Key",
|
||||
IsActive: true,
|
||||
}
|
||||
database.DB.Create(&apiKey)
|
||||
suite.apiKey = apiKey.Key
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestHealthCheck() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), "healthy", response["status"])
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestLogin() {
|
||||
loginData := map[string]string{
|
||||
"username": "testuser",
|
||||
"password": "testpass",
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(loginData)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response api.LoginResponse
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.NotEmpty(suite.T(), response.Token)
|
||||
assert.Equal(suite.T(), "testuser", response.User.Username)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestLoginInvalidCredentials() {
|
||||
loginData := map[string]string{
|
||||
"username": "testuser",
|
||||
"password": "wrongpass",
|
||||
}
|
||||
|
||||
jsonData, _ := json.Marshal(loginData)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 401, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestSubmitJobWithAPIKey() {
|
||||
// Create test audio file
|
||||
audioFile := suite.createTestAudioFile()
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add audio file
|
||||
file, err := os.Open(audioFile)
|
||||
assert.NoError(suite.T(), err)
|
||||
defer file.Close()
|
||||
|
||||
part, err := writer.CreateFormFile("audio", "test.mp3")
|
||||
assert.NoError(suite.T(), err)
|
||||
io.Copy(part, file)
|
||||
|
||||
// Add other form fields
|
||||
writer.WriteField("title", "Test Audio")
|
||||
writer.WriteField("model", "base")
|
||||
writer.WriteField("diarization", "false")
|
||||
|
||||
writer.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/transcription/submit", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response models.TranscriptionJob
|
||||
err = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.NotEmpty(suite.T(), response.ID)
|
||||
assert.Equal(suite.T(), "Test Audio", *response.Title)
|
||||
assert.Equal(suite.T(), models.StatusPending, response.Status)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestSubmitJobWithJWT() {
|
||||
audioFile := suite.createTestAudioFile()
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
file, err := os.Open(audioFile)
|
||||
assert.NoError(suite.T(), err)
|
||||
defer file.Close()
|
||||
|
||||
part, err := writer.CreateFormFile("audio", "test.mp3")
|
||||
assert.NoError(suite.T(), err)
|
||||
io.Copy(part, file)
|
||||
|
||||
writer.WriteField("title", "JWT Test Audio")
|
||||
writer.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/transcription/submit", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+suite.token)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestSubmitJobUnauthorized() {
|
||||
audioFile := suite.createTestAudioFile()
|
||||
defer os.Remove(audioFile)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
file, err := os.Open(audioFile)
|
||||
assert.NoError(suite.T(), err)
|
||||
defer file.Close()
|
||||
|
||||
part, err := writer.CreateFormFile("audio", "test.mp3")
|
||||
assert.NoError(suite.T(), err)
|
||||
io.Copy(part, file)
|
||||
writer.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/transcription/submit", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
// No authentication header
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 401, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetJobStatus() {
|
||||
// Create test job
|
||||
job := suite.createTestJob()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/transcription/%s/status", job.ID), nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response models.TranscriptionJob
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), job.ID, response.ID)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetJobStatusNotFound() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/transcription/nonexistent/status", nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 404, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetJobByID() {
|
||||
job := suite.createTestJob()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/transcription/%s", job.ID), nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response models.TranscriptionJob
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), job.ID, response.ID)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestListJobs() {
|
||||
// Create multiple test jobs
|
||||
job1 := suite.createTestJob()
|
||||
job2 := suite.createTestJob()
|
||||
_ = job1
|
||||
_ = job2
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/transcription/list", nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
jobs := response["jobs"].([]interface{})
|
||||
assert.GreaterOrEqual(suite.T(), len(jobs), 2)
|
||||
|
||||
pagination := response["pagination"].(map[string]interface{})
|
||||
assert.Equal(suite.T(), float64(1), pagination["page"])
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestListJobsWithPagination() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/transcription/list?page=1&limit=5", nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetTranscriptNotCompleted() {
|
||||
job := suite.createTestJob()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/transcription/%s/transcript", job.ID), nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 400, w.Code)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetTranscriptCompleted() {
|
||||
job := suite.createTestJobWithTranscript()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/transcription/%s/transcript", job.ID), nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
assert.Equal(suite.T(), job.ID, response["job_id"])
|
||||
assert.NotNil(suite.T(), response["transcript"])
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetSupportedModels() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/transcription/models", nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
models := response["models"].([]interface{})
|
||||
languages := response["languages"].([]interface{})
|
||||
|
||||
assert.Greater(suite.T(), len(models), 0)
|
||||
assert.Greater(suite.T(), len(languages), 0)
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) TestGetQueueStats() {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/admin/queue/stats", nil)
|
||||
req.Header.Set("X-API-Key", suite.apiKey)
|
||||
suite.router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(suite.T(), 200, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
assert.Contains(suite.T(), response, "queue_size")
|
||||
assert.Contains(suite.T(), response, "workers")
|
||||
assert.Contains(suite.T(), response, "pending_jobs")
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (suite *APITestSuite) createTestAudioFile() string {
|
||||
// Create a dummy MP3 file for testing
|
||||
tmpFile, err := os.CreateTemp("", "test_audio_*.mp3")
|
||||
assert.NoError(suite.T(), err)
|
||||
|
||||
// Write some dummy data
|
||||
tmpFile.WriteString("dummy mp3 data for testing")
|
||||
tmpFile.Close()
|
||||
|
||||
return tmpFile.Name()
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) createTestJob() *models.TranscriptionJob {
|
||||
job := &models.TranscriptionJob{
|
||||
ID: "test-job-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
Title: stringPtr("Test Job"),
|
||||
Status: models.StatusPending,
|
||||
AudioPath: "test/path/audio.mp3",
|
||||
Parameters: models.WhisperXParams{
|
||||
Model: "base",
|
||||
BatchSize: 16,
|
||||
ComputeType: "float16",
|
||||
Device: "auto",
|
||||
},
|
||||
}
|
||||
|
||||
database.DB.Create(job)
|
||||
return job
|
||||
}
|
||||
|
||||
func (suite *APITestSuite) createTestJobWithTranscript() *models.TranscriptionJob {
|
||||
transcript := `{"segments": [{"start": 0.0, "end": 5.0, "text": "This is a test transcript."}], "language": "en"}`
|
||||
|
||||
job := &models.TranscriptionJob{
|
||||
ID: "test-job-with-transcript-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
Title: stringPtr("Test Job with Transcript"),
|
||||
Status: models.StatusCompleted,
|
||||
AudioPath: "test/path/audio.mp3",
|
||||
Transcript: &transcript,
|
||||
Parameters: models.WhisperXParams{
|
||||
Model: "base",
|
||||
BatchSize: 16,
|
||||
ComputeType: "float16",
|
||||
Device: "auto",
|
||||
},
|
||||
}
|
||||
|
||||
database.DB.Create(job)
|
||||
return job
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestAPITestSuite(t *testing.T) {
|
||||
suite.Run(t, new(APITestSuite))
|
||||
}
|
||||
91
tests/auth_test.go
Normal file
91
tests/auth_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"scriberr/internal/auth"
|
||||
"scriberr/internal/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
password := "testpassword123"
|
||||
|
||||
hash, err := auth.HashPassword(password)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, hash)
|
||||
assert.NotEqual(t, password, hash)
|
||||
}
|
||||
|
||||
func TestCheckPassword(t *testing.T) {
|
||||
password := "testpassword123"
|
||||
hash, _ := auth.HashPassword(password)
|
||||
|
||||
// Test correct password
|
||||
assert.True(t, auth.CheckPassword(password, hash))
|
||||
|
||||
// Test incorrect password
|
||||
assert.False(t, auth.CheckPassword("wrongpassword", hash))
|
||||
}
|
||||
|
||||
func TestAuthService_GenerateToken(t *testing.T) {
|
||||
authService := auth.NewAuthService("test-secret")
|
||||
|
||||
user := &models.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
token, err := authService.GenerateToken(user)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
}
|
||||
|
||||
func TestAuthService_ValidateToken(t *testing.T) {
|
||||
authService := auth.NewAuthService("test-secret")
|
||||
|
||||
user := &models.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
// Generate a token
|
||||
token, err := authService.GenerateToken(user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Validate the token
|
||||
claims, err := authService.ValidateToken(token)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, claims)
|
||||
assert.Equal(t, user.ID, claims.UserID)
|
||||
assert.Equal(t, user.Username, claims.Username)
|
||||
}
|
||||
|
||||
func TestAuthService_ValidateInvalidToken(t *testing.T) {
|
||||
authService := auth.NewAuthService("test-secret")
|
||||
|
||||
// Test invalid token
|
||||
claims, err := authService.ValidateToken("invalid-token")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, claims)
|
||||
}
|
||||
|
||||
func TestAuthService_ValidateTokenWithWrongSecret(t *testing.T) {
|
||||
authService1 := auth.NewAuthService("secret1")
|
||||
authService2 := auth.NewAuthService("secret2")
|
||||
|
||||
user := &models.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
// Generate token with first service
|
||||
token, err := authService1.GenerateToken(user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to validate with second service (different secret)
|
||||
claims, err := authService2.ValidateToken(token)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, claims)
|
||||
}
|
||||
150
tests/models_test.go
Normal file
150
tests/models_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTranscriptionJob_BeforeCreate(t *testing.T) {
|
||||
job := &models.TranscriptionJob{
|
||||
Title: stringPtr("Test Job"),
|
||||
AudioPath: "/path/to/audio.mp3",
|
||||
Status: models.StatusPending,
|
||||
}
|
||||
|
||||
// ID should be empty initially
|
||||
assert.Empty(t, job.ID)
|
||||
|
||||
// Simulate GORM BeforeCreate hook
|
||||
err := job.BeforeCreate(nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// ID should be generated
|
||||
assert.NotEmpty(t, job.ID)
|
||||
assert.Len(t, job.ID, 36) // UUID length
|
||||
}
|
||||
|
||||
func TestAPIKey_BeforeCreate(t *testing.T) {
|
||||
apiKey := &models.APIKey{
|
||||
Name: "Test API Key",
|
||||
Description: stringPtr("Test description"),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// Key should be empty initially
|
||||
assert.Empty(t, apiKey.Key)
|
||||
|
||||
// Simulate GORM BeforeCreate hook
|
||||
err := apiKey.BeforeCreate(nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Key should be generated
|
||||
assert.NotEmpty(t, apiKey.Key)
|
||||
assert.Len(t, apiKey.Key, 36) // UUID length
|
||||
}
|
||||
|
||||
func TestJobStatus_Values(t *testing.T) {
|
||||
assert.Equal(t, "pending", string(models.StatusPending))
|
||||
assert.Equal(t, "processing", string(models.StatusProcessing))
|
||||
assert.Equal(t, "completed", string(models.StatusCompleted))
|
||||
assert.Equal(t, "failed", string(models.StatusFailed))
|
||||
}
|
||||
|
||||
func TestWhisperXParams_Defaults(t *testing.T) {
|
||||
params := models.WhisperXParams{
|
||||
Model: "base",
|
||||
BatchSize: 16,
|
||||
ComputeType: "float16",
|
||||
Device: "auto",
|
||||
VadFilter: false,
|
||||
VadOnset: 0.500,
|
||||
VadOffset: 0.363,
|
||||
}
|
||||
|
||||
assert.Equal(t, "base", params.Model)
|
||||
assert.Equal(t, 16, params.BatchSize)
|
||||
assert.Equal(t, "float16", params.ComputeType)
|
||||
assert.Equal(t, "auto", params.Device)
|
||||
assert.False(t, params.VadFilter)
|
||||
assert.Equal(t, 0.500, params.VadOnset)
|
||||
assert.Equal(t, 0.363, params.VadOffset)
|
||||
}
|
||||
|
||||
func TestTranscriptionJob_JSON_Serialization(t *testing.T) {
|
||||
transcript := `{"segments": [{"start": 0.0, "end": 5.0, "text": "Test"}], "language": "en"}`
|
||||
|
||||
job := models.TranscriptionJob{
|
||||
ID: "test-job-123",
|
||||
Title: stringPtr("Test Job"),
|
||||
Status: models.StatusCompleted,
|
||||
AudioPath: "/path/to/audio.mp3",
|
||||
Transcript: &transcript,
|
||||
Diarization: true,
|
||||
Summary: stringPtr("Test summary"),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Parameters: models.WhisperXParams{
|
||||
Model: "base",
|
||||
Language: stringPtr("en"),
|
||||
BatchSize: 16,
|
||||
ComputeType: "float16",
|
||||
Device: "auto",
|
||||
VadFilter: true,
|
||||
VadOnset: 0.500,
|
||||
VadOffset: 0.363,
|
||||
MinSpeakers: intPtr(2),
|
||||
MaxSpeakers: intPtr(5),
|
||||
},
|
||||
}
|
||||
|
||||
// Test JSON serialization
|
||||
jsonData, err := json.Marshal(job)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, jsonData)
|
||||
|
||||
// Test JSON deserialization
|
||||
var deserializedJob models.TranscriptionJob
|
||||
err = json.Unmarshal(jsonData, &deserializedJob)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, job.ID, deserializedJob.ID)
|
||||
assert.Equal(t, job.Status, deserializedJob.Status)
|
||||
assert.Equal(t, job.AudioPath, deserializedJob.AudioPath)
|
||||
assert.Equal(t, job.Diarization, deserializedJob.Diarization)
|
||||
assert.Equal(t, *job.Title, *deserializedJob.Title)
|
||||
assert.Equal(t, *job.Summary, *deserializedJob.Summary)
|
||||
assert.Equal(t, *job.Transcript, *deserializedJob.Transcript)
|
||||
assert.Equal(t, job.Parameters.Model, deserializedJob.Parameters.Model)
|
||||
assert.Equal(t, *job.Parameters.Language, *deserializedJob.Parameters.Language)
|
||||
assert.Equal(t, *job.Parameters.MinSpeakers, *deserializedJob.Parameters.MinSpeakers)
|
||||
assert.Equal(t, *job.Parameters.MaxSpeakers, *deserializedJob.Parameters.MaxSpeakers)
|
||||
}
|
||||
|
||||
func TestUser_JSON_Serialization(t *testing.T) {
|
||||
user := models.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
Password: "hashedpassword", // Should not appear in JSON
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(user)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check that password is not included in JSON
|
||||
var jsonMap map[string]interface{}
|
||||
err = json.Unmarshal(jsonData, &jsonMap)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), jsonMap["id"])
|
||||
assert.Equal(t, "testuser", jsonMap["username"])
|
||||
assert.NotContains(t, jsonMap, "password")
|
||||
}
|
||||
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
229
tests/queue_test.go
Normal file
229
tests/queue_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scriberr/internal/database"
|
||||
"scriberr/internal/models"
|
||||
"scriberr/internal/queue"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockJobProcessor is a mock implementation of JobProcessor
|
||||
type MockJobProcessor struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockJobProcessor) ProcessJob(jobID string) error {
|
||||
args := m.Called(jobID)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestTaskQueue_NewTaskQueue(t *testing.T) {
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
tq := queue.NewTaskQueue(2, mockProcessor)
|
||||
|
||||
assert.NotNil(t, tq)
|
||||
}
|
||||
|
||||
func TestTaskQueue_EnqueueJob(t *testing.T) {
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
tq := queue.NewTaskQueue(1, mockProcessor)
|
||||
|
||||
jobID := "test-job-123"
|
||||
err := tq.EnqueueJob(jobID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTaskQueue_GetQueueStats(t *testing.T) {
|
||||
// Initialize test database
|
||||
database.Initialize("test_queue.db")
|
||||
defer func() {
|
||||
database.Close()
|
||||
// Note: In a real test environment, you'd clean up the test DB
|
||||
}()
|
||||
|
||||
// Create some test jobs
|
||||
testJobs := []models.TranscriptionJob{
|
||||
{
|
||||
ID: "pending-1",
|
||||
Status: models.StatusPending,
|
||||
AudioPath: "test1.mp3",
|
||||
},
|
||||
{
|
||||
ID: "processing-1",
|
||||
Status: models.StatusProcessing,
|
||||
AudioPath: "test2.mp3",
|
||||
},
|
||||
{
|
||||
ID: "completed-1",
|
||||
Status: models.StatusCompleted,
|
||||
AudioPath: "test3.mp3",
|
||||
},
|
||||
{
|
||||
ID: "failed-1",
|
||||
Status: models.StatusFailed,
|
||||
AudioPath: "test4.mp3",
|
||||
},
|
||||
}
|
||||
|
||||
for _, job := range testJobs {
|
||||
database.DB.Create(&job)
|
||||
}
|
||||
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
tq := queue.NewTaskQueue(2, mockProcessor)
|
||||
|
||||
stats := tq.GetQueueStats()
|
||||
|
||||
assert.Contains(t, stats, "queue_size")
|
||||
assert.Contains(t, stats, "queue_capacity")
|
||||
assert.Contains(t, stats, "workers")
|
||||
assert.Contains(t, stats, "pending_jobs")
|
||||
assert.Contains(t, stats, "processing_jobs")
|
||||
assert.Contains(t, stats, "completed_jobs")
|
||||
assert.Contains(t, stats, "failed_jobs")
|
||||
|
||||
assert.Equal(t, 2, stats["workers"])
|
||||
assert.Equal(t, int64(1), stats["pending_jobs"])
|
||||
assert.Equal(t, int64(1), stats["processing_jobs"])
|
||||
assert.Equal(t, int64(1), stats["completed_jobs"])
|
||||
assert.Equal(t, int64(1), stats["failed_jobs"])
|
||||
}
|
||||
|
||||
func TestTaskQueue_GetJobStatus(t *testing.T) {
|
||||
database.Initialize("test_queue_status.db")
|
||||
defer func() {
|
||||
database.Close()
|
||||
}()
|
||||
|
||||
// Create test job
|
||||
testJob := models.TranscriptionJob{
|
||||
ID: "status-test-job",
|
||||
Status: models.StatusPending,
|
||||
AudioPath: "test.mp3",
|
||||
Title: stringPtr("Test Job"),
|
||||
}
|
||||
database.DB.Create(&testJob)
|
||||
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
tq := queue.NewTaskQueue(1, mockProcessor)
|
||||
|
||||
// Test getting existing job
|
||||
job, err := tq.GetJobStatus("status-test-job")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, job)
|
||||
assert.Equal(t, "status-test-job", job.ID)
|
||||
assert.Equal(t, models.StatusPending, job.Status)
|
||||
|
||||
// Test getting non-existent job
|
||||
job, err = tq.GetJobStatus("non-existent-job")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, job)
|
||||
}
|
||||
|
||||
func TestTaskQueue_StartAndStop(t *testing.T) {
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
tq := queue.NewTaskQueue(1, mockProcessor)
|
||||
|
||||
// Start the queue
|
||||
tq.Start()
|
||||
|
||||
// Wait a bit to ensure workers are running
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Stop the queue
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
tq.Stop()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Ensure stop completes within reasonable time
|
||||
select {
|
||||
case <-done:
|
||||
// Success
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("TaskQueue.Stop() took too long")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskQueue_ProcessJob(t *testing.T) {
|
||||
database.Initialize("test_queue_process.db")
|
||||
defer func() {
|
||||
database.Close()
|
||||
}()
|
||||
|
||||
// Create test job
|
||||
testJob := models.TranscriptionJob{
|
||||
ID: "process-test-job",
|
||||
Status: models.StatusPending,
|
||||
AudioPath: "test.mp3",
|
||||
}
|
||||
database.DB.Create(&testJob)
|
||||
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
mockProcessor.On("ProcessJob", "process-test-job").Return(nil)
|
||||
|
||||
tq := queue.NewTaskQueue(1, mockProcessor)
|
||||
tq.Start()
|
||||
|
||||
// Enqueue the job
|
||||
err := tq.EnqueueJob("process-test-job")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Wait for processing to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
tq.Stop()
|
||||
|
||||
// Verify the mock was called
|
||||
mockProcessor.AssertExpectations(t)
|
||||
|
||||
// Verify job status was updated
|
||||
var updatedJob models.TranscriptionJob
|
||||
database.DB.Where("id = ?", "process-test-job").First(&updatedJob)
|
||||
assert.Equal(t, models.StatusCompleted, updatedJob.Status)
|
||||
}
|
||||
|
||||
func TestTaskQueue_ProcessJobWithError(t *testing.T) {
|
||||
database.Initialize("test_queue_error.db")
|
||||
defer func() {
|
||||
database.Close()
|
||||
}()
|
||||
|
||||
// Create test job
|
||||
testJob := models.TranscriptionJob{
|
||||
ID: "error-test-job",
|
||||
Status: models.StatusPending,
|
||||
AudioPath: "test.mp3",
|
||||
}
|
||||
database.DB.Create(&testJob)
|
||||
|
||||
mockProcessor := &MockJobProcessor{}
|
||||
mockProcessor.On("ProcessJob", "error-test-job").Return(assert.AnError)
|
||||
|
||||
tq := queue.NewTaskQueue(1, mockProcessor)
|
||||
tq.Start()
|
||||
|
||||
// Enqueue the job
|
||||
err := tq.EnqueueJob("error-test-job")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Wait for processing to complete
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
tq.Stop()
|
||||
|
||||
// Verify the mock was called
|
||||
mockProcessor.AssertExpectations(t)
|
||||
|
||||
// Verify job status was updated to failed
|
||||
var updatedJob models.TranscriptionJob
|
||||
database.DB.Where("id = ?", "error-test-job").First(&updatedJob)
|
||||
assert.Equal(t, models.StatusFailed, updatedJob.Status)
|
||||
assert.NotNil(t, updatedJob.ErrorMessage)
|
||||
}
|
||||
BIN
tests/test_queue.db
Normal file
BIN
tests/test_queue.db
Normal file
Binary file not shown.
BIN
tests/test_queue_error.db
Normal file
BIN
tests/test_queue_error.db
Normal file
Binary file not shown.
BIN
tests/test_queue_process.db
Normal file
BIN
tests/test_queue_process.db
Normal file
Binary file not shown.
BIN
tests/test_queue_status.db
Normal file
BIN
tests/test_queue_status.db
Normal file
Binary file not shown.
24
web/frontend/.gitignore
vendored
Normal file
24
web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
69
web/frontend/README.md
Normal file
69
web/frontend/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
21
web/frontend/components.json
Normal file
21
web/frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
23
web/frontend/eslint.config.js
Normal file
23
web/frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
web/frontend/index.html
Normal file
16
web/frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scriberr - Audio Transcription</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4197
web/frontend/package-lock.json
generated
Normal file
4197
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
web/frontend/package.json
Normal file
41
web/frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/node": "^24.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.540.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
1
web/frontend/public/vite.svg
Normal file
1
web/frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
web/frontend/src/App.css
Normal file
1
web/frontend/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* App-specific styles */
|
||||
7
web/frontend/src/App.tsx
Normal file
7
web/frontend/src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Homepage } from './components/Homepage'
|
||||
|
||||
function App() {
|
||||
return <Homepage />
|
||||
}
|
||||
|
||||
export default App
|
||||
1
web/frontend/src/assets/react.svg
Normal file
1
web/frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
149
web/frontend/src/components/AudioFilesTable.tsx
Normal file
149
web/frontend/src/components/AudioFilesTable.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { CheckCircle, Clock, XCircle, Loader2 } from "lucide-react";
|
||||
|
||||
interface AudioFile {
|
||||
id: string;
|
||||
title?: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
created_at: string;
|
||||
audio_path: string;
|
||||
}
|
||||
|
||||
interface AudioFilesTableProps {
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
export function AudioFilesTable({ refreshTrigger }: AudioFilesTableProps) {
|
||||
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchAudioFiles = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/v1/transcription/list", {
|
||||
headers: {
|
||||
"X-API-Key": "dev-api-key-123",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setAudioFiles(data.jobs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audio files:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAudioFiles();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
const iconSize = 16;
|
||||
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CheckCircle size={iconSize} className="text-magnum-400" />;
|
||||
case "processing":
|
||||
return (
|
||||
<Loader2 size={iconSize} className="text-blue-400 animate-spin" />
|
||||
);
|
||||
case "failed":
|
||||
return <XCircle size={iconSize} className="text-magenta-400" />;
|
||||
case "pending":
|
||||
default:
|
||||
return <Clock size={iconSize} className="text-purple-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getFileName = (audioPath: string) => {
|
||||
const parts = audioPath.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-xl p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-700 rounded w-1/4 mb-6"></div>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-700/50 rounded-lg"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-50 mb-2">Audio Files</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{audioFiles.length} file{audioFiles.length !== 1 ? "s" : ""} total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{audioFiles.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="text-5xl mb-4 opacity-50">🎵</div>
|
||||
<h3 className="text-lg font-medium text-gray-300 mb-2">
|
||||
No audio files yet
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
Upload your first audio file to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-300">
|
||||
Title
|
||||
</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-300">
|
||||
Date Added
|
||||
</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{audioFiles.map((file) => (
|
||||
<tr
|
||||
key={file.id}
|
||||
className="hover:bg-gray-700 transition-colors duration-200"
|
||||
>
|
||||
<td className="px-6 py-3">
|
||||
<span className="text-gray-50 font-medium">
|
||||
{file.title || getFileName(file.audio_path)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-gray-300 text-sm">
|
||||
{formatDate(file.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-3">{getStatusIcon(file.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
web/frontend/src/components/Header.tsx
Normal file
52
web/frontend/src/components/Header.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScriberrLogo } from './ScriberrLogo'
|
||||
|
||||
interface HeaderProps {
|
||||
onFileSelect: (file: File) => void
|
||||
}
|
||||
|
||||
export function Header({ onFileSelect }: HeaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAddAudioClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
onFileSelect(file)
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-gray-800 rounded-xl p-8 mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Logo */}
|
||||
<ScriberrLogo />
|
||||
|
||||
{/* Right side - Add Audio Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={handleAddAudioClick}
|
||||
className="bg-neon-100 hover:bg-neon-200 text-gray-900 font-medium px-8 py-3 rounded-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-lg hover:shadow-neon-100/20"
|
||||
>
|
||||
Add Audio
|
||||
</Button>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
43
web/frontend/src/components/Homepage.tsx
Normal file
43
web/frontend/src/components/Homepage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react'
|
||||
import { Header } from './Header'
|
||||
import { AudioFilesTable } from './AudioFilesTable'
|
||||
|
||||
export function Homepage() {
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
const handleFileSelect = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('audio', file)
|
||||
formData.append('title', file.name.replace(/\.[^/.]+$/, ""))
|
||||
formData.append('model', 'base')
|
||||
formData.append('diarization', 'false')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/transcription/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-API-Key': 'dev-api-key-123'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh the table to show the new file
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
} else {
|
||||
alert('Failed to upload file')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error uploading file')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<div className="mx-auto px-8 py-6" style={{ width: '90vw' }}>
|
||||
<Header onFileSelect={handleFileSelect} />
|
||||
<AudioFilesTable refreshTrigger={refreshTrigger} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
web/frontend/src/components/ScriberrLogo.tsx
Normal file
9
web/frontend/src/components/ScriberrLogo.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export function ScriberrLogo({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<span className="text-2xl font-semibold text-gray-50 tracking-wide uppercase">
|
||||
SCRIBERR
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
web/frontend/src/components/ui/button.tsx
Normal file
59
web/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
web/frontend/src/components/ui/card.tsx
Normal file
92
web/frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
21
web/frontend/src/components/ui/input.tsx
Normal file
21
web/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
138
web/frontend/src/index.css
Normal file
138
web/frontend/src/index.css
Normal file
@@ -0,0 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--color-blue-50: oklch(97% 4% 259deg);
|
||||
--color-blue-100: oklch(91% 11% 258deg);
|
||||
--color-blue-200: oklch(82% 22% 257deg);
|
||||
--color-blue-300: oklch(75% 32% 258deg);
|
||||
--color-blue-400: oklch(68% 43% 257deg);
|
||||
--color-blue-500: oklch(58% 58% 260deg);
|
||||
--color-blue-600: oklch(50% 56% 262deg);
|
||||
--color-blue-700: oklch(41% 51% 263deg);
|
||||
--color-blue-800: oklch(30% 42% 265deg);
|
||||
--color-blue-900: oklch(22% 28% 266deg);
|
||||
--color-gray-50: oklch(96.72% 0 0);
|
||||
--color-gray-100: oklch(90.67% 0 0);
|
||||
--color-gray-200: oklch(82.66% 0 0);
|
||||
--color-gray-300: oklch(73.16% 0 0);
|
||||
--color-gray-400: oklch(64.34% 0 0);
|
||||
--color-gray-500: oklch(54.17% 0 0);
|
||||
--color-gray-600: oklch(43.86% 0 0);
|
||||
--color-gray-700: oklch(26.86% 0 0);
|
||||
--color-gray-800: oklch(20.02% 0 0);
|
||||
--color-gray-900: oklch(0% 0 0);
|
||||
--color-gray-950: oklch(21% 2% 286deg);
|
||||
--color-magenta-50: oklch(97% 4% 348deg);
|
||||
--color-magenta-100: oklch(91% 12% 354deg);
|
||||
--color-magenta-200: oklch(83% 27% 355deg);
|
||||
--color-magenta-300: oklch(75% 39% 358deg);
|
||||
--color-magenta-400: oklch(68% 47% 359deg);
|
||||
--color-magenta-500: oklch(58% 52% 3deg);
|
||||
--color-magenta-600: oklch(49% 46% 2deg);
|
||||
--color-magenta-700: oklch(39% 38% 0deg);
|
||||
--color-magenta-800: oklch(32% 33% 360deg);
|
||||
--color-magenta-900: oklch(21% 16% 358deg);
|
||||
--color-magnum-50: oklch(98% 4% 85deg);
|
||||
--color-magnum-100: oklch(96% 10% 88deg);
|
||||
--color-magnum-200: oklch(92% 18% 83deg);
|
||||
--color-magnum-300: oklch(86% 28% 79deg);
|
||||
--color-magnum-400: oklch(81% 34% 71deg);
|
||||
--color-magnum-500: oklch(74% 41% 60deg);
|
||||
--color-magnum-600: oklch(67% 42% 52deg);
|
||||
--color-magnum-700: oklch(58% 38% 48deg);
|
||||
--color-magnum-800: oklch(49% 31% 46deg);
|
||||
--color-magnum-900: oklch(42% 25% 48deg);
|
||||
--color-purple-50: oklch(97% 4% 309deg);
|
||||
--color-purple-100: oklch(91% 13% 303deg);
|
||||
--color-purple-200: oklch(83% 25% 303deg);
|
||||
--color-purple-300: oklch(76% 37% 303deg);
|
||||
--color-purple-400: oklch(70% 47% 302deg);
|
||||
--color-purple-500: oklch(60% 63% 298deg);
|
||||
--color-purple-600: oklch(51% 55% 298deg);
|
||||
--color-purple-700: oklch(41% 46% 297deg);
|
||||
--color-purple-800: oklch(33% 38% 298deg);
|
||||
--color-purple-900: oklch(23% 20% 300deg);
|
||||
--color-neon-100: oklch(95% 41% 123deg);
|
||||
--color-neon-200: oklch(88% 42% 123deg);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-gray-900 text-gray-50;
|
||||
}
|
||||
}
|
||||
6
web/frontend/src/lib/utils.ts
Normal file
6
web/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
11
web/frontend/src/main.tsx
Normal file
11
web/frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
1
web/frontend/src/vite-env.d.ts
vendored
Normal file
1
web/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
35
web/frontend/tsconfig.app.json
Normal file
35
web/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
web/frontend/tsconfig.json
Normal file
15
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
web/frontend/tsconfig.node.json
Normal file
25
web/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
web/frontend/vite.config.ts
Normal file
22
web/frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from "path"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
assetsDir: "assets",
|
||||
},
|
||||
base: "/",
|
||||
})
|
||||
1
web/frontend/vite.svg
Normal file
1
web/frontend/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
Reference in New Issue
Block a user