This commit is contained in:
rishikanthc
2025-08-21 15:48:55 -07:00
parent 37b0ca8f9f
commit 96aeaf1454
61 changed files with 9768 additions and 0 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

40
build.sh Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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()
}

View 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
View 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,
}
}

View 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",
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

17
internal/web/dist/index.html vendored Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

BIN
tests/test_queue_error.db Normal file

Binary file not shown.

BIN
tests/test_queue_process.db Normal file

Binary file not shown.

BIN
tests/test_queue_status.db Normal file

Binary file not shown.

24
web/frontend/.gitignore vendored Normal file
View 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
View 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...
},
},
])
```

View 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"
}

View 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
View 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

File diff suppressed because it is too large Load Diff

41
web/frontend/package.json Normal file
View 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"
}
}

View 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
View File

@@ -0,0 +1 @@
/* App-specific styles */

7
web/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { Homepage } from './components/Homepage'
function App() {
return <Homepage />
}
export default App

View 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

View 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>
);
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 }

View 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,
}

View 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
View 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;
}
}

View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@@ -0,0 +1,15 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View 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"]
}

View 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
View 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