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