Initial commit: Whisper API with FastAPI, GPU support and Admin Dashboard
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -0,0 +1,27 @@
|
||||
# Whisper API Environment Configuration
|
||||
|
||||
# Server Settings
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Whisper Model Configuration
|
||||
WHISPER_MODEL=large-v3
|
||||
WHISPER_DEVICE=cuda
|
||||
WHISPER_COMPUTE_TYPE=float16
|
||||
|
||||
# API Authentication
|
||||
# Multiple API keys separated by commas
|
||||
API_KEYS=sk-your-api-key-here,sk-another-api-key
|
||||
|
||||
# Admin Panel Authentication
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=-whisper12510-
|
||||
|
||||
# Data Retention (days)
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# Optional: Sentry for error tracking
|
||||
# SENTRY_DSN=https://your-sentry-dsn-here
|
||||
|
||||
# Optional: HuggingFace Cache (for model downloads)
|
||||
# HF_HOME=/app/models
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Models (large files)
|
||||
models/*
|
||||
!models/.gitkeep
|
||||
|
||||
# Uploads
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Data
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
335
README.md
Normal file
335
README.md
Normal file
@@ -0,0 +1,335 @@
|
||||
# Whisper API
|
||||
|
||||
Eine lokale Whisper-API mit GPU-Beschleunigung und Web-Admin-Interface für die Transkription von Audio-Dateien.
|
||||
|
||||
## Features
|
||||
|
||||
- **OpenAI-kompatible API** - Drop-in Ersatz für OpenAI Whisper API
|
||||
- **GPU-beschleunigt** - Nutzt NVIDIA GPUs (CUDA) für schnelle Transkription
|
||||
- **Default: large-v3** - Beste Qualität mit deiner RTX 3090
|
||||
- **Web-Admin-Interface** - API-Key Management und Statistiken unter `/admin`
|
||||
- **API-Key Authentifizierung** - Sichere Zugriffskontrolle
|
||||
- **Cross-Platform** - Docker-basiert, läuft auf Windows und Linux
|
||||
- **Automatische Cleanup** - Logs nach 30 Tagen automatisch gelöscht
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Client/App │────▶│ FastAPI App │────▶│ Whisper GPU │
|
||||
│ (Clawdbot etc) │ │ (Port 8000) │ │ (large-v3) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ /admin Panel │
|
||||
│ - Key Mgmt │
|
||||
│ - Dashboard │
|
||||
│ - Logs │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Schnellstart
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Docker Desktop (Windows) oder Docker + docker-compose (Linux)
|
||||
- NVIDIA GPU mit CUDA-Unterstützung (RTX 3090)
|
||||
- NVIDIA Container Toolkit installiert
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Repository klonen:**
|
||||
```bash
|
||||
git clone https://gitea.ragtag.rocks/b0rborad/whisper-api.git
|
||||
cd whisper-api
|
||||
```
|
||||
|
||||
2. **Umgebungsvariablen konfigurieren:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Bearbeite .env nach deinen Wünschen
|
||||
```
|
||||
|
||||
3. **Docker-Container starten:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Erster Start:**
|
||||
- Das `large-v3` Modell (~3GB) wird automatisch heruntergeladen
|
||||
- Dies kann 5-10 Minuten dauern
|
||||
- Status überprüfen: `docker-compose logs -f`
|
||||
|
||||
### Verifizierung
|
||||
|
||||
```bash
|
||||
# Health-Check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# API-Info
|
||||
curl http://localhost:8000/v1/models
|
||||
```
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
### Authentifizierung
|
||||
|
||||
Alle API-Endpunkte (außer `/health` und `/admin`) benötigen einen API-Key:
|
||||
|
||||
```bash
|
||||
Authorization: Bearer sk-dein-api-key-hier
|
||||
```
|
||||
|
||||
### Endpunkte
|
||||
|
||||
#### POST /v1/audio/transcriptions
|
||||
|
||||
Transkribiert eine Audio-Datei.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer sk-dein-api-key" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "file=@/pfad/zur/audio.mp3" \
|
||||
-F "model=large-v3" \
|
||||
-F "language=de" \
|
||||
-F "response_format=json"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"text": "Hallo Welt, das ist ein Test."
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /v1/audio/transcriptions (mit Timestamps)
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/audio/transcriptions \
|
||||
-H "Authorization: Bearer sk-dein-api-key" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "timestamp_granularities[]=word" \
|
||||
-F "response_format=verbose_json"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"text": "Hallo Welt",
|
||||
"segments": [
|
||||
{
|
||||
"id": 0,
|
||||
"start": 0.0,
|
||||
"end": 1.5,
|
||||
"text": "Hallo Welt",
|
||||
"words": [
|
||||
{"word": "Hallo", "start": 0.0, "end": 0.5},
|
||||
{"word": "Welt", "start": 0.6, "end": 1.2}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /v1/models
|
||||
|
||||
Liste verfügbarer Modelle.
|
||||
|
||||
#### GET /health
|
||||
|
||||
Health-Check mit GPU-Status.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"gpu": {
|
||||
"available": true,
|
||||
"name": "NVIDIA GeForce RTX 3090",
|
||||
"vram_used": "2.1 GB",
|
||||
"vram_total": "24.0 GB"
|
||||
},
|
||||
"model": "large-v3",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
## Admin-Interface
|
||||
|
||||
Das Web-Interface ist erreichbar unter: `http://localhost:8000/admin`
|
||||
|
||||
### Login
|
||||
|
||||
- **Benutzername:** `admin` (konfigurierbar in `.env`)
|
||||
- **Passwort:** `-whisper12510-` (konfigurierbar in `.env`)
|
||||
|
||||
### Features
|
||||
|
||||
- **Dashboard:** Übersicht über Nutzung, Performance-Statistiken
|
||||
- **API-Keys:** Verwalten (erstellen, deaktivieren, löschen)
|
||||
- **Logs:** Detaillierte Transkriptions-Logs mit Filter
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### .env.example
|
||||
|
||||
```bash
|
||||
# Server
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Whisper
|
||||
WHISPER_MODEL=large-v3
|
||||
WHISPER_DEVICE=cuda
|
||||
WHISPER_COMPUTE_TYPE=float16
|
||||
|
||||
# Authentifizierung
|
||||
# Mehrere API-Keys mit Komma trennen
|
||||
API_KEYS=sk-dein-erster-key,sk-dein-zweiter-key
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=-whisper12510-
|
||||
|
||||
# Daten-Retention (Tage)
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# Optional: Sentry für Error-Tracking
|
||||
# SENTRY_DSN=https://...
|
||||
```
|
||||
|
||||
### Docker-Compose Anpassungen
|
||||
|
||||
```yaml
|
||||
services:
|
||||
whisper-api:
|
||||
# ...
|
||||
environment:
|
||||
- PORT=8000 # Änderbar
|
||||
- WHISPER_MODEL=large-v3
|
||||
volumes:
|
||||
- ./models:/app/models # Persistiert Modelle
|
||||
- ./data:/app/data # SQLite Datenbank
|
||||
- ./uploads:/app/uploads # Temporäre Uploads
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
## Migration zu Linux
|
||||
|
||||
Die Docker-Konfiguration ist plattformunabhängig. Für Linux:
|
||||
|
||||
1. **NVIDIA Docker installieren:**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
distribution=$(. /etc/os-release;echo $ID$VERSION_ID)
|
||||
curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add -
|
||||
curl -s -L https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nvidia-docker2
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
2. **Projekt klonen und starten:**
|
||||
```bash
|
||||
git clone https://gitea.ragtag.rocks/b0rborad/whisper-api.git
|
||||
cd whisper-api
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. **GPU-Passthrough verifizieren:**
|
||||
```bash
|
||||
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi
|
||||
```
|
||||
|
||||
## Integration mit Clawdbot
|
||||
|
||||
Für die Integration in einen Clawdbot Skill:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_URL = "http://localhost:8000/v1/audio/transcriptions"
|
||||
API_KEY = "sk-dein-api-key"
|
||||
|
||||
def transcribe_audio(audio_path):
|
||||
with open(audio_path, "rb") as f:
|
||||
response = requests.post(
|
||||
API_URL,
|
||||
headers={"Authorization": f"Bearer {API_KEY}"},
|
||||
files={"file": f},
|
||||
data={"language": "de"}
|
||||
)
|
||||
return response.json()["text"]
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
Mit RTX 3090 und large-v3:
|
||||
- **1 Minute Audio:** ~3-5 Sekunden Verarbeitungszeit
|
||||
- **VRAM-Nutzung:** ~10 GB
|
||||
- **Batch-Verarbeitung:** Möglich für parallele Requests
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### GPU nicht erkannt
|
||||
|
||||
```bash
|
||||
# NVIDIA Container Toolkit prüfen
|
||||
docker run --rm --gpus all nvidia/cuda:12.0-base nvidia-smi
|
||||
|
||||
# Logs prüfen
|
||||
docker-compose logs whisper-api
|
||||
```
|
||||
|
||||
### Modell-Download langsam
|
||||
|
||||
```bash
|
||||
# Manuelles Downloaden möglich
|
||||
mkdir -p models
|
||||
# Modelle werden von HuggingFace heruntergeladen
|
||||
```
|
||||
|
||||
### Port belegt
|
||||
|
||||
```bash
|
||||
# Port in .env ändern
|
||||
PORT=8001
|
||||
```
|
||||
|
||||
## Backup
|
||||
|
||||
Wichtige Daten:
|
||||
- `./data/` - SQLite Datenbank (API-Keys, Logs)
|
||||
- `./models/` - Heruntergeladene Whisper-Modelle
|
||||
- `./.env` - Konfiguration
|
||||
|
||||
```bash
|
||||
# Backup erstellen
|
||||
tar -czvf whisper-api-backup.tar.gz data/ models/ .env
|
||||
```
|
||||
|
||||
## Lizenz
|
||||
|
||||
MIT License - Siehe LICENSE Datei
|
||||
|
||||
## Support
|
||||
|
||||
Bei Problemen:
|
||||
1. Logs prüfen: `docker-compose logs -f`
|
||||
2. Health-Check: `curl http://localhost:8000/health`
|
||||
3. Issue auf Gitea erstellen
|
||||
|
||||
---
|
||||
|
||||
**Erstellt für:** b0rborad @ ragtag.rocks
|
||||
**Hardware:** Dual RTX 3090 Setup
|
||||
**Zweck:** Clawdbot Skill Integration
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
whisper-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: whisper-api
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8000}:8000"
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HOST=0.0.0.0
|
||||
- WHISPER_MODEL=${WHISPER_MODEL:-large-v3}
|
||||
- WHISPER_DEVICE=${WHISPER_DEVICE:-cuda}
|
||||
- WHISPER_COMPUTE_TYPE=${WHISPER_COMPUTE_TYPE:-float16}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- ADMIN_USER=${ADMIN_USER:-admin}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:--whisper12510-}
|
||||
- LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-30}
|
||||
- DATABASE_URL=sqlite:///app/data/whisper_api.db
|
||||
volumes:
|
||||
- ./models:/app/models
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
44
docker/Dockerfile
Normal file
44
docker/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||
|
||||
# Prevent interactive prompts
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3.11 \
|
||||
python3.11-pip \
|
||||
python3.11-venv \
|
||||
ffmpeg \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Python 3.11 as default
|
||||
RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1
|
||||
RUN update-alternatives --install /usr/bin/pip pip /usr/bin/pip3 1
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/models /app/data /app/uploads
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONPATH=/app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
0
models/.gitkeep
Normal file
0
models/.gitkeep
Normal file
35
requirements.txt
Normal file
35
requirements.txt
Normal file
@@ -0,0 +1,35 @@
|
||||
# Web Framework
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.3
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Whisper
|
||||
openai-whisper==20231117
|
||||
faster-whisper==0.10.0
|
||||
torch==2.1.2
|
||||
torchaudio==2.1.2
|
||||
|
||||
# Database
|
||||
sqlalchemy==2.0.25
|
||||
alembic==1.13.1
|
||||
|
||||
# Audio processing
|
||||
librosa==0.10.1
|
||||
soundfile==0.12.1
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.26.0
|
||||
|
||||
# Monitoring
|
||||
psutil==5.9.6
|
||||
|
||||
# Scheduling
|
||||
apscheduler==3.10.4
|
||||
|
||||
# Optional: Sentry
|
||||
# sentry-sdk==1.39.2
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
43
src/api/health.py
Normal file
43
src/api/health.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter
|
||||
import torch
|
||||
import psutil
|
||||
|
||||
from src.config import settings
|
||||
from src.services.whisper_service import get_model_info
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint with GPU status"""
|
||||
|
||||
# Get GPU info if available
|
||||
gpu_info = {"available": False}
|
||||
if torch.cuda.is_available():
|
||||
gpu_name = torch.cuda.get_device_name(0)
|
||||
vram_total = torch.cuda.get_device_properties(0).total_memory / (1024**3)
|
||||
vram_allocated = torch.cuda.memory_allocated(0) / (1024**3)
|
||||
|
||||
gpu_info = {
|
||||
"available": True,
|
||||
"name": gpu_name,
|
||||
"vram_total_gb": round(vram_total, 2),
|
||||
"vram_used_gb": round(vram_allocated, 2),
|
||||
"vram_free_gb": round(vram_total - vram_allocated, 2)
|
||||
}
|
||||
|
||||
# Get system info
|
||||
memory = psutil.virtual_memory()
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"model": settings.whisper_model,
|
||||
"gpu": gpu_info,
|
||||
"system": {
|
||||
"cpu_percent": psutil.cpu_percent(interval=1),
|
||||
"memory_percent": memory.percent,
|
||||
"memory_available_gb": round(memory.available / (1024**3), 2)
|
||||
}
|
||||
}
|
||||
160
src/api/transcriptions.py
Normal file
160
src/api/transcriptions.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from fastapi import APIRouter, File, UploadFile, Form, HTTPException, Depends, Header
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing import Optional, List
|
||||
import time
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
from src.config import settings
|
||||
from src.services.whisper_service import transcribe_audio
|
||||
from src.services.stats_service import log_usage
|
||||
from src.database.db import get_db
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def verify_api_key(authorization: Optional[str] = Header(None)):
|
||||
"""Verify API key from Authorization header"""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
# Extract Bearer token
|
||||
if not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Invalid authorization format")
|
||||
|
||||
api_key = authorization.replace("Bearer ", "").strip()
|
||||
valid_keys = settings.get_api_keys_list()
|
||||
|
||||
if not valid_keys:
|
||||
raise HTTPException(status_code=500, detail="No API keys configured")
|
||||
|
||||
if api_key not in valid_keys:
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
return api_key
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models(api_key: str = Depends(verify_api_key)):
|
||||
"""List available models (OpenAI compatible)"""
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "whisper-1",
|
||||
"object": "model",
|
||||
"created": 1677532384,
|
||||
"owned_by": "openai"
|
||||
},
|
||||
{
|
||||
"id": "large-v3",
|
||||
"object": "model",
|
||||
"created": 1698796800,
|
||||
"owned_by": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/audio/transcriptions")
|
||||
async def create_transcription(
|
||||
file: UploadFile = File(...),
|
||||
model: str = Form("whisper-1"),
|
||||
language: Optional[str] = Form(None),
|
||||
prompt: Optional[str] = Form(None),
|
||||
response_format: str = Form("json"),
|
||||
temperature: float = Form(0.0),
|
||||
timestamp_granularities: Optional[List[str]] = Form(None),
|
||||
api_key: str = Depends(verify_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Transcribe audio file (OpenAI compatible endpoint)
|
||||
|
||||
- **file**: Audio file (mp3, mp4, mpeg, mpga, m4a, wav, webm)
|
||||
- **model**: Model ID (whisper-1 or large-v3)
|
||||
- **language**: Language code (e.g., 'de', 'en')
|
||||
- **response_format**: json, text, srt, verbose_json, vtt
|
||||
- **timestamp_granularities**: word, segment (for verbose_json)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
# Validate file type
|
||||
allowed_extensions = {'.mp3', '.mp4', '.mpeg', '.mpga', '.m4a', '.wav', '.webm'}
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file format: {file_ext}"
|
||||
)
|
||||
|
||||
# Save uploaded file
|
||||
temp_filename = f"{api_key[:8]}_{int(time.time())}_{file.filename}"
|
||||
temp_path = os.path.join(settings.uploads_path, temp_filename)
|
||||
|
||||
with open(temp_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
file_size = len(content)
|
||||
|
||||
# Transcribe
|
||||
include_word_timestamps = timestamp_granularities and "word" in timestamp_granularities
|
||||
|
||||
result = await transcribe_audio(
|
||||
audio_path=temp_path,
|
||||
language=language,
|
||||
include_word_timestamps=include_word_timestamps
|
||||
)
|
||||
|
||||
processing_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Log usage
|
||||
await log_usage(
|
||||
db=db,
|
||||
api_key=api_key,
|
||||
endpoint="/v1/audio/transcriptions",
|
||||
file_size=file_size,
|
||||
duration=result.get("duration"),
|
||||
processing_time=processing_time,
|
||||
model=settings.whisper_model,
|
||||
status="success"
|
||||
)
|
||||
|
||||
# Format response based on requested format
|
||||
if response_format == "text":
|
||||
return result["text"]
|
||||
elif response_format == "verbose_json":
|
||||
return result
|
||||
else:
|
||||
return {"text": result["text"]}
|
||||
|
||||
except Exception as e:
|
||||
processing_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# Log error
|
||||
await log_usage(
|
||||
db=db,
|
||||
api_key=api_key,
|
||||
endpoint="/v1/audio/transcriptions",
|
||||
file_size=file_size if 'file_size' in locals() else None,
|
||||
duration=None,
|
||||
processing_time=processing_time,
|
||||
model=settings.whisper_model,
|
||||
status="error",
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
finally:
|
||||
# Cleanup temp file
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except:
|
||||
pass
|
||||
51
src/config.py
Normal file
51
src/config.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings"""
|
||||
# Server
|
||||
port: int = 8000
|
||||
host: str = "0.0.0.0"
|
||||
|
||||
# Whisper
|
||||
whisper_model: str = "large-v3"
|
||||
whisper_device: str = "cuda"
|
||||
whisper_compute_type: str = "float16"
|
||||
|
||||
# Auth
|
||||
api_keys: str = ""
|
||||
admin_user: str = "admin"
|
||||
admin_password: str = "-whisper12510-"
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:///app/data/whisper_api.db"
|
||||
|
||||
# Retention
|
||||
log_retention_days: int = 30
|
||||
|
||||
# Paths
|
||||
models_path: str = "/app/models"
|
||||
uploads_path: str = "/app/uploads"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
env_prefix = ""
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
def get_api_keys_list(self):
|
||||
"""Get list of valid API keys"""
|
||||
if not self.api_keys:
|
||||
return []
|
||||
return [key.strip() for key in self.api_keys.split(",") if key.strip()]
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings():
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
32
src/database/__init__.py
Normal file
32
src/database/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Create engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
from src.database import models
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency to get database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
32
src/database/db.py
Normal file
32
src/database/db.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Create engine
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.database_url else {}
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Base class for models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database tables"""
|
||||
from src.database import models
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency to get database session"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
30
src/database/models.py
Normal file
30
src/database/models.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Float, Text
|
||||
from sqlalchemy.sql import func
|
||||
from src.database.db import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key_hash = Column(String(64), unique=True, index=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
usage_count = Column(Integer, default=0)
|
||||
|
||||
|
||||
class UsageLog(Base):
|
||||
__tablename__ = "usage_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
api_key_id = Column(Integer, nullable=True)
|
||||
endpoint = Column(String(100))
|
||||
file_size_bytes = Column(Integer, nullable=True)
|
||||
duration_seconds = Column(Float, nullable=True)
|
||||
processing_time_ms = Column(Integer)
|
||||
model_used = Column(String(50))
|
||||
status = Column(String(20)) # success, error
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
49
src/main.py
Normal file
49
src/main.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from contextlib import asynccontextmanager
|
||||
import os
|
||||
|
||||
from src.api import transcriptions, health
|
||||
from src.web import routes as web_routes
|
||||
from src.database.db import init_db
|
||||
from src.services.cleanup_service import start_cleanup_scheduler
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager"""
|
||||
# Startup
|
||||
await init_db()
|
||||
start_cleanup_scheduler()
|
||||
yield
|
||||
# Shutdown
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Whisper API",
|
||||
description="Local Whisper API with GPU acceleration",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="src/web/static"), name="static")
|
||||
|
||||
# Include API routes
|
||||
app.include_router(health.router, tags=["Health"])
|
||||
app.include_router(transcriptions.router, prefix="/v1", tags=["Transcriptions"])
|
||||
|
||||
# Include web routes
|
||||
app.include_router(web_routes.router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
# Root redirect to admin
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Whisper API is running", "admin": "/admin", "docs": "/docs"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
0
src/services/__init__.py
Normal file
0
src/services/__init__.py
Normal file
44
src/services/cleanup_service.py
Normal file
44
src/services/cleanup_service.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from src.config import settings
|
||||
from src.database.db import SessionLocal
|
||||
from src.services.stats_service import cleanup_old_logs
|
||||
|
||||
scheduler = None
|
||||
|
||||
|
||||
async def cleanup_job():
|
||||
"""Daily cleanup job"""
|
||||
print(f"[{datetime.now()}] Running cleanup job...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = await cleanup_old_logs(db, settings.log_retention_days)
|
||||
print(f"[{datetime.now()}] Cleaned up {deleted} old log entries")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_cleanup_scheduler():
|
||||
"""Start the background cleanup scheduler"""
|
||||
global scheduler
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
# Run cleanup daily at 3 AM
|
||||
scheduler.add_job(
|
||||
cleanup_job,
|
||||
trigger=CronTrigger(hour=3, minute=0),
|
||||
id="cleanup_logs",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
print("Cleanup scheduler started (runs daily at 3:00 AM)")
|
||||
|
||||
|
||||
def stop_cleanup_scheduler():
|
||||
"""Stop the scheduler"""
|
||||
if scheduler:
|
||||
scheduler.shutdown()
|
||||
112
src/services/stats_service.py
Normal file
112
src/services/stats_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from src.database.models import ApiKey, UsageLog
|
||||
|
||||
|
||||
def hash_api_key(key: str) -> str:
|
||||
"""Hash API key for storage"""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
|
||||
async def log_usage(
|
||||
db: Session,
|
||||
api_key: str,
|
||||
endpoint: str,
|
||||
file_size: Optional[int],
|
||||
duration: Optional[float],
|
||||
processing_time: int,
|
||||
model: str,
|
||||
status: str,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""Log API usage"""
|
||||
key_hash = hash_api_key(api_key)
|
||||
|
||||
# Find or create API key record
|
||||
db_key = db.query(ApiKey).filter(ApiKey.key_hash == key_hash).first()
|
||||
if not db_key:
|
||||
db_key = ApiKey(
|
||||
key_hash=key_hash,
|
||||
description=f"Auto-created for key {api_key[:8]}..."
|
||||
)
|
||||
db.add(db_key)
|
||||
db.commit()
|
||||
db.refresh(db_key)
|
||||
|
||||
# Update last used
|
||||
db_key.last_used_at = datetime.utcnow()
|
||||
db_key.usage_count += 1
|
||||
|
||||
# Create usage log
|
||||
log = UsageLog(
|
||||
api_key_id=db_key.id,
|
||||
endpoint=endpoint,
|
||||
file_size_bytes=file_size,
|
||||
duration_seconds=duration,
|
||||
processing_time_ms=processing_time,
|
||||
model_used=model,
|
||||
status=status,
|
||||
error_message=error_message
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def get_usage_stats(db: Session, days: int = 30):
|
||||
"""Get usage statistics for dashboard"""
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
# Total requests
|
||||
total_requests = db.query(UsageLog).filter(UsageLog.created_at >= since).count()
|
||||
|
||||
# Success rate
|
||||
success_count = db.query(UsageLog).filter(
|
||||
UsageLog.created_at >= since,
|
||||
UsageLog.status == "success"
|
||||
).count()
|
||||
success_rate = (success_count / total_requests * 100) if total_requests > 0 else 0
|
||||
|
||||
# Average processing time
|
||||
avg_time = db.query(func.avg(UsageLog.processing_time_ms)).filter(
|
||||
UsageLog.created_at >= since,
|
||||
UsageLog.status == "success"
|
||||
).scalar() or 0
|
||||
|
||||
# Daily stats for chart
|
||||
daily_stats = db.query(
|
||||
func.date(UsageLog.created_at).label("date"),
|
||||
func.count().label("count"),
|
||||
func.avg(UsageLog.processing_time_ms).label("avg_time")
|
||||
).filter(
|
||||
UsageLog.created_at >= since
|
||||
).group_by(
|
||||
func.date(UsageLog.created_at)
|
||||
).order_by("date").all()
|
||||
|
||||
# Recent logs
|
||||
recent_logs = db.query(UsageLog).order_by(
|
||||
UsageLog.created_at.desc()
|
||||
).limit(50).all()
|
||||
|
||||
return {
|
||||
"total_requests": total_requests,
|
||||
"success_rate": round(success_rate, 2),
|
||||
"avg_processing_time_ms": round(avg_time, 2),
|
||||
"daily_stats": [
|
||||
{"date": str(stat.date), "count": stat.count, "avg_time": round(stat.avg_time or 0, 2)}
|
||||
for stat in daily_stats
|
||||
],
|
||||
"recent_logs": recent_logs
|
||||
}
|
||||
|
||||
|
||||
async def cleanup_old_logs(db: Session, retention_days: int):
|
||||
"""Delete logs older than retention period"""
|
||||
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||
deleted = db.query(UsageLog).filter(UsageLog.created_at < cutoff).delete()
|
||||
db.commit()
|
||||
return deleted
|
||||
109
src/services/whisper_service.py
Normal file
109
src/services/whisper_service.py
Normal file
@@ -0,0 +1,109 @@
|
||||
import whisper
|
||||
import torch
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from src.config import settings
|
||||
|
||||
# Global model cache
|
||||
_model = None
|
||||
_executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
|
||||
def load_model():
|
||||
"""Load Whisper model"""
|
||||
global _model
|
||||
if _model is None:
|
||||
print(f"Loading Whisper model: {settings.whisper_model}")
|
||||
_model = whisper.load_model(
|
||||
settings.whisper_model,
|
||||
device=settings.whisper_device,
|
||||
download_root=settings.models_path
|
||||
)
|
||||
print(f"Model loaded on {settings.whisper_device}")
|
||||
return _model
|
||||
|
||||
|
||||
def get_model_info():
|
||||
"""Get model information"""
|
||||
model = load_model()
|
||||
return {
|
||||
"name": settings.whisper_model,
|
||||
"device": settings.whisper_device,
|
||||
"loaded": _model is not None
|
||||
}
|
||||
|
||||
|
||||
def _transcribe_sync(
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
include_word_timestamps: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Synchronous transcription (runs in thread pool)"""
|
||||
model = load_model()
|
||||
|
||||
# Set decode options
|
||||
decode_options = {}
|
||||
if language:
|
||||
decode_options["language"] = language
|
||||
|
||||
# Transcribe
|
||||
result = model.transcribe(
|
||||
audio_path,
|
||||
**decode_options,
|
||||
word_timestamps=include_word_timestamps
|
||||
)
|
||||
|
||||
# Format result
|
||||
output = {
|
||||
"text": result["text"].strip(),
|
||||
"task": "transcribe",
|
||||
"language": result.get("language", "unknown"),
|
||||
"duration": result.get("duration", 0)
|
||||
}
|
||||
|
||||
# Add segments if available
|
||||
if "segments" in result:
|
||||
segments = []
|
||||
for segment in result["segments"]:
|
||||
seg_data = {
|
||||
"id": segment["id"],
|
||||
"start": segment["start"],
|
||||
"end": segment["end"],
|
||||
"text": segment["text"].strip()
|
||||
}
|
||||
|
||||
# Add word timestamps if requested
|
||||
if include_word_timestamps and "words" in segment:
|
||||
seg_data["words"] = [
|
||||
{
|
||||
"word": word["word"].strip(),
|
||||
"start": word["start"],
|
||||
"end": word["end"]
|
||||
}
|
||||
for word in segment["words"]
|
||||
]
|
||||
|
||||
segments.append(seg_data)
|
||||
|
||||
output["segments"] = segments
|
||||
|
||||
return output
|
||||
|
||||
|
||||
async def transcribe_audio(
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
include_word_timestamps: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Async wrapper for transcription"""
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
_executor,
|
||||
_transcribe_sync,
|
||||
audio_path,
|
||||
language,
|
||||
include_word_timestamps
|
||||
)
|
||||
229
src/templates/base.html
Normal file
229
src/templates/base.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Whisper API Admin{% endblock %}</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.nav a:hover, .nav a.active {
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #48bb78;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #38a169;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f56565;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #e53e3e;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f7fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
border: 1px solid #9ae6b4;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
border: 1px solid #fc8181;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.api-key-display {
|
||||
background: #f7fafc;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
116
src/templates/dashboard.html
Normal file
116
src/templates/dashboard.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Whisper API Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎯 Whisper API Dashboard</h1>
|
||||
<div class="nav">
|
||||
<a href="/admin" class="active">Dashboard</a>
|
||||
<a href="/admin/keys">API Keys</a>
|
||||
<a href="/admin/logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.total_requests }}</div>
|
||||
<div class="stat-label">Requests (30 Tage)</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.success_rate }}%</div>
|
||||
<div class="stat-label">Success Rate</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ stats.avg_processing_time_ms }}ms</div>
|
||||
<div class="stat-label">Ø Processing Time</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ model }}</div>
|
||||
<div class="stat-label">Model</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📊 Usage Chart (Last 30 Days)</h2>
|
||||
<canvas id="usageChart" height="100"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📝 Recent Activity</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Processing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in stats.recent_logs[:10] %}
|
||||
<tr>
|
||||
<td>{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ log.endpoint }}</td>
|
||||
<td>
|
||||
{% if log.duration_seconds %}
|
||||
{{ "%.1f"|format(log.duration_seconds) }}s
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if log.status == 'success' else 'danger' }}">
|
||||
{{ log.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ log.processing_time_ms }}ms</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>ℹ️ System Info</h2>
|
||||
<p><strong>Model:</strong> {{ model }}</p>
|
||||
<p><strong>Log Retention:</strong> {{ retention_days }} days</p>
|
||||
<p><strong>API Version:</strong> v1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
const ctx = document.getElementById('usageChart').getContext('2d');
|
||||
const dailyStats = {{ stats.daily_stats | tojson }};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dailyStats.map(s => s.date),
|
||||
datasets: [{
|
||||
label: 'Requests',
|
||||
data: dailyStats.map(s => s.count),
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.2)',
|
||||
borderColor: 'rgba(102, 126, 234, 1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
110
src/templates/keys.html
Normal file
110
src/templates/keys.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}API Keys - Whisper API Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 API Key Management</h1>
|
||||
<div class="nav">
|
||||
<a href="/admin">Dashboard</a>
|
||||
<a href="/admin/keys" class="active">API Keys</a>
|
||||
<a href="/admin/logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert-success">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if new_key %}
|
||||
<div class="card" style="background: #c6f6d5; border: 2px solid #48bb78;">
|
||||
<h2>✨ New API Key Generated</h2>
|
||||
<p>Copy this key now - it will not be shown again!</p>
|
||||
<div class="api-key-display">{{ new_key }}</div>
|
||||
<button onclick="copyToClipboard('{{ new_key }}')" class="btn">Copy to Clipboard</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<h2>Create New API Key</h2>
|
||||
<form method="POST" action="/admin/keys/create">
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<input type="text" id="description" name="description" placeholder="e.g., Clawdbot Integration">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">Generate New API Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Active API Keys</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Usage Count</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key in keys %}
|
||||
<tr>
|
||||
<td>{{ key.id }}</td>
|
||||
<td>{{ key.description or '-' }}</td>
|
||||
<td>{{ key.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if key.last_used_at %}
|
||||
{{ key.last_used_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ key.usage_count }}</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if key.is_active else 'danger' }}">
|
||||
{{ 'Active' if key.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/admin/keys/{{ key.id }}/toggle" style="display: inline;">
|
||||
<button type="submit" class="btn" style="padding: 5px 10px; font-size: 12px;">
|
||||
{{ 'Deactivate' if key.is_active else 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/admin/keys/{{ key.id }}/delete" style="display: inline; margin-left: 5px;"
|
||||
onsubmit="return confirm('Are you sure you want to delete this API key?');">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 5px 10px; font-size: 12px;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not keys %}
|
||||
<p style="text-align: center; color: #666; margin-top: 20px;">
|
||||
No API keys found. Create one above.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
alert('API Key copied to clipboard!');
|
||||
}, function(err) {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
39
src/templates/login.html
Normal file
39
src/templates/login.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Whisper API Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div style="max-width: 400px; margin: 100px auto;">
|
||||
<div class="card">
|
||||
<h2 style="text-align: center; margin-bottom: 30px;">🔑 Admin Login</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="/admin/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 10px;">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style="text-align: center; margin-top: 20px; color: #666; font-size: 12px;">
|
||||
Default: admin / -whisper12510-
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
0
src/web/__init__.py
Normal file
0
src/web/__init__.py
Normal file
187
src/web/routes.py
Normal file
187
src/web/routes.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request, Form, HTTPException, Depends
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.config import settings
|
||||
from src.database.db import get_db
|
||||
from src.database.models import ApiKey, UsageLog
|
||||
from src.services.stats_service import get_usage_stats, hash_api_key
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="src/templates")
|
||||
|
||||
# Simple session store (in production, use Redis or proper session management)
|
||||
active_sessions = {}
|
||||
|
||||
|
||||
def generate_api_key():
|
||||
"""Generate a new API key"""
|
||||
return f"sk-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def check_admin_auth(request: Request):
|
||||
"""Check if user is authenticated as admin"""
|
||||
session_token = request.cookies.get("admin_session")
|
||||
if not session_token or session_token not in active_sessions:
|
||||
raise HTTPException(status_code=302, detail="/admin/login")
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, error: Optional[str] = None):
|
||||
"""Show login page"""
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": error
|
||||
})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...)
|
||||
):
|
||||
"""Process login"""
|
||||
if username == settings.admin_user and password == settings.admin_password:
|
||||
session_token = uuid.uuid4().hex
|
||||
active_sessions[session_token] = {
|
||||
"username": username,
|
||||
"login_time": datetime.utcnow()
|
||||
}
|
||||
|
||||
response = RedirectResponse(url="/admin", status_code=302)
|
||||
response.set_cookie(
|
||||
key="admin_session",
|
||||
value=session_token,
|
||||
httponly=True,
|
||||
max_age=86400 # 24 hours
|
||||
)
|
||||
return response
|
||||
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request,
|
||||
"error": "Invalid username or password"
|
||||
})
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Logout user"""
|
||||
session_token = request.cookies.get("admin_session")
|
||||
if session_token in active_sessions:
|
||||
del active_sessions[session_token]
|
||||
|
||||
response = RedirectResponse(url="/admin/login", status_code=302)
|
||||
response.delete_cookie("admin_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request, db: Session = Depends(get_db)):
|
||||
"""Admin dashboard"""
|
||||
try:
|
||||
check_admin_auth(request)
|
||||
except HTTPException as e:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
stats = await get_usage_stats(db, days=30)
|
||||
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"request": request,
|
||||
"stats": stats,
|
||||
"model": settings.whisper_model,
|
||||
"retention_days": settings.log_retention_days
|
||||
})
|
||||
|
||||
|
||||
@router.get("/keys", response_class=HTMLResponse)
|
||||
async def manage_keys(request: Request, db: Session = Depends(get_db)):
|
||||
"""API key management page"""
|
||||
try:
|
||||
check_admin_auth(request)
|
||||
except HTTPException as e:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
keys = db.query(ApiKey).order_by(ApiKey.created_at.desc()).all()
|
||||
|
||||
return templates.TemplateResponse("keys.html", {
|
||||
"request": request,
|
||||
"keys": keys
|
||||
})
|
||||
|
||||
|
||||
@router.post("/keys/create")
|
||||
async def create_key(
|
||||
request: Request,
|
||||
description: str = Form(""),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create new API key"""
|
||||
try:
|
||||
check_admin_auth(request)
|
||||
except HTTPException as e:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
new_key = generate_api_key()
|
||||
key_hash = hash_api_key(new_key)
|
||||
|
||||
api_key = ApiKey(
|
||||
key_hash=key_hash,
|
||||
description=description or f"Created {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
||||
)
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
|
||||
# Store the plain key temporarily to show to user (only once)
|
||||
return templates.TemplateResponse("keys.html", {
|
||||
"request": request,
|
||||
"keys": db.query(ApiKey).order_by(ApiKey.created_at.desc()).all(),
|
||||
"new_key": new_key,
|
||||
"message": "API Key created successfully! Copy it now - it won't be shown again."
|
||||
})
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/toggle")
|
||||
async def toggle_key(
|
||||
request: Request,
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Toggle API key active status"""
|
||||
try:
|
||||
check_admin_auth(request)
|
||||
except HTTPException as e:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if api_key:
|
||||
api_key.is_active = not api_key.is_active
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url="/admin/keys", status_code=302)
|
||||
|
||||
|
||||
@router.post("/keys/{key_id}/delete")
|
||||
async def delete_key(
|
||||
request: Request,
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete API key"""
|
||||
try:
|
||||
check_admin_auth(request)
|
||||
except HTTPException as e:
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||
if api_key:
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse(url="/admin/keys", status_code=302)
|
||||
0
uploads/.gitkeep
Normal file
0
uploads/.gitkeep
Normal file
Reference in New Issue
Block a user