Initial commit

This commit is contained in:
2026-04-12 22:50:50 +02:00
parent aca115f272
commit c9c7bdc204
52 changed files with 9939 additions and 2 deletions

43
.env.example Normal file
View File

@@ -0,0 +1,43 @@
# StreamDock Konfiguration
# Kopiere diese Datei nach .env und passe die Werte an
# Server
STREAMDOCK_HOST=0.0.0.0
STREAMDOCK_PORT=8080
STREAMDOCK_BASE_URL=http://localhost:8080
# JWT Secret (unbedingt ändern!)
STREAMDOCK_JWT_SECRET=change-me-to-a-random-secret-string
# Datenbank
STREAMDOCK_DB_PATH=./data/config/streamdock.db
# Aufnahme-Verzeichnis
STREAMDOCK_RECORDINGS_PATH=./data/recordings
# Avatar-Verzeichnis
STREAMDOCK_AVATARS_PATH=./data/avatars
# Admin Account (wird beim ersten Start erstellt)
STREAMDOCK_ADMIN_USER=admin
STREAMDOCK_ADMIN_PASSWORD=admin
STREAMDOCK_ADMIN_EMAIL=admin@example.com
# Email (SMTP) - Globaler Absender
STREAMDOCK_SMTP_HOST=
STREAMDOCK_SMTP_PORT=587
STREAMDOCK_SMTP_USER=
STREAMDOCK_SMTP_PASSWORD=
STREAMDOCK_SMTP_FROM=streamdock@example.com
STREAMDOCK_SMTP_FROM_NAME=StreamDock
# Last.fm API (global - Benutzer verbinden ihren eigenen Account)
STREAMDOCK_LASTFM_API_KEY=
STREAMDOCK_LASTFM_API_SECRET=
# Plik (optional)
STREAMDOCK_PLIK_URL=
STREAMDOCK_PLIK_API_KEY=
# Ntfy (optional - Standard-Server)
STREAMDOCK_NTFY_DEFAULT_SERVER=https://ntfy.sh

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Binaries
/streamdock
/streamdock.exe
/bin/
/dist/
# Data directories
/data/config/*.db
/data/recordings/
/data/avatars/
# Environment
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Go
/vendor/
*.test
*.out
coverage.html
# Build
/tmp/

62
Dockerfile Normal file
View File

@@ -0,0 +1,62 @@
# ============================================
# StreamDock - Multi-Stage Docker Build
# ============================================
# --- Build Stage ---
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git
WORKDIR /build
# Dependencies zuerst (Caching)
COPY go.mod go.sum ./
RUN go mod download
# Source kopieren und bauen
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o streamdock ./cmd/streamdock
# --- Runtime Stage ---
FROM alpine:3.21
RUN apk add --no-cache \
ca-certificates \
tzdata \
ffmpeg \
su-exec \
&& adduser -D -h /app streamdock
WORKDIR /app
# Binary kopieren
COPY --from=builder /build/streamdock .
# Web-Assets kopieren
COPY --from=builder /build/web ./web
# Verzeichnisse erstellen
RUN mkdir -p /app/data/config /app/data/recordings /app/data/avatars \
&& chown -R streamdock:streamdock /app
# Entrypoint-Script: Rechte sicherstellen, dann als streamdock-User starten
COPY --from=builder --chmod=755 /build/entrypoint.sh /app/entrypoint.sh
# Volumes
VOLUME ["/app/data/config", "/app/data/recordings"]
# Ports
EXPOSE 8080
# Umgebungsvariablen
ENV STREAMDOCK_HOST=0.0.0.0 \
STREAMDOCK_PORT=8080 \
STREAMDOCK_DB_PATH=/app/data/config/streamdock.db \
STREAMDOCK_RECORDINGS_PATH=/app/data/recordings \
STREAMDOCK_AVATARS_PATH=/app/data/avatars
# Health Check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/api/auth/me || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]

58
Makefile Normal file
View File

@@ -0,0 +1,58 @@
.PHONY: build run dev test clean docker docker-run
# Variablen
BINARY=streamdock
MAIN=./cmd/streamdock
# === Entwicklung ===
# Projekt bauen
build:
go build -o $(BINARY) $(MAIN)
# Projekt starten
run: build
./$(BINARY)
# Abhängigkeiten laden
deps:
go mod download
go mod tidy
# Tests ausführen
test:
go test ./... -v
# Linting
lint:
golangci-lint run ./...
# Aufräumen
clean:
rm -f $(BINARY)
rm -rf data/config/streamdock.db
# === Docker ===
# Docker Image bauen
docker:
docker build -t streamdock:latest .
# Docker Container starten
docker-run:
docker compose up -d
# Docker Container stoppen
docker-stop:
docker compose down
# Docker Logs anzeigen
docker-logs:
docker compose logs -f
# === Datenbank ===
# Datenbank zurücksetzen (Vorsicht!)
db-reset:
rm -f data/config/streamdock.db
@echo "Datenbank gelöscht. Wird beim nächsten Start neu erstellt."

View File

@@ -1,11 +1,46 @@
# template_repository
# StreamDock
Web-basierter Stream-Player und Recorder mit Benutzerverwaltung, Last.fm-Integration und zeitgesteuerter Aufnahme.
> **⚠️ Alpha-Software:** StreamDock befindet sich derzeit in einem frühen Entwicklungsstadium (Alpha). Funktionen können sich jederzeit ändern, und es ist mit Fehlern zu rechnen. **Ein Betrieb im öffentlichen Internet wird ausdrücklich nicht empfohlen.** Die Nutzung sollte auf vertrauenswürdige Netzwerke oder VPN-Umgebungen beschränkt werden.
---
Wichtig: Link für Lizenz anpassen.
## Features
- 🎵 **Stream-Verwaltung** Audio- und Video-Streams abspielen, aufnehmen und verwalten
- 🎛️ **7-Band Equalizer** mit Presets (Flat, Bass, Rock, Vocal) und Echtzeit-Visualizer
- ⏱️ **Zeitgesteuerte Aufnahmen** per Cron-Ausdruck oder Einzeltermin
- 📻 **Radio-Browser** 30.000+ freie Radiosender durchsuchen und hinzufügen
- 🎧 **Last.fm Scrobbling** automatisches Melden gehörter Titel
- 🔔 **Benachrichtigungen** E-Mail, Webhook & Ntfy
- 👥 **Benutzerverwaltung** Rollen, Quotas, Profilbilder
- 📱 **PWA** als native App installierbar
- 🐳 **Docker** Multi-Stage Build, Non-Root-Container
## Schnellstart
```bash
git clone <repo-url> streamdock && cd streamdock
cp .env.example .env # .env anpassen (JWT_SECRET + Admin-Passwort ändern!)
docker compose up -d # http://localhost:8080 → Login: admin / admin
```
> Detaillierte Anleitung: [docs/installation.md](docs/installation.md)
## Dokumentation
Die vollständige Dokumentation befindet sich im Verzeichnis [`docs/`](docs/README.md):
## Tech Stack
| Komponente | Technologie |
|---|---|
| Backend | Go 1.23+, Chi Router |
| Datenbank | SQLite (modernc.org/sqlite) |
| Frontend | Alpine.js, Web Audio API |
| Auth | JWT + bcrypt |
| Container | Docker, Alpine Linux |
<p align="center">
<img src="https://assets.techniverse.net/f1/git/graphics/gray0-catonline.svg" alt="">

99
cmd/streamdock/main.go Normal file
View File

@@ -0,0 +1,99 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"streamdock/internal/api"
"streamdock/internal/config"
"streamdock/internal/db"
"streamdock/internal/recorder"
"streamdock/internal/scheduler"
"streamdock/internal/videoproxy"
)
func main() {
// Konfiguration laden
cfg, err := config.Load()
if err != nil {
log.Fatalf("Fehler beim Laden der Konfiguration: %v", err)
}
// Verzeichnisse erstellen
for _, dir := range []string{cfg.RecordingsPath, cfg.AvatarsPath} {
if err := os.MkdirAll(dir, 0750); err != nil {
log.Fatalf("Fehler beim Erstellen von %s: %v", dir, err)
}
}
// Datenbank initialisieren
database, err := db.New(cfg.DBPath)
if err != nil {
log.Fatalf("Fehler bei Datenbankverbindung: %v", err)
}
defer database.Close()
if err := database.Migrate(); err != nil {
log.Fatalf("Fehler bei Datenbankmigration: %v", err)
}
// Admin-Account sicherstellen
if err := database.EnsureAdmin(cfg.AdminUser, cfg.AdminPassword, cfg.AdminEmail); err != nil {
log.Fatalf("Fehler beim Erstellen des Admin-Accounts: %v", err)
}
// Recorder initialisieren
rec := recorder.New(cfg.RecordingsPath)
// Video-Proxy initialisieren
vp := videoproxy.New(cfg.RecordingsPath)
defer vp.Cleanup()
// Scheduler initialisieren
sched := scheduler.New(database, rec)
defer sched.Stop()
// API-Router erstellen (registriert auch den Scheduler-Callback)
router := api.NewRouter(cfg, database, rec, sched, vp)
// Scheduler starten (lädt aktive Zeitpläne)
sched.Start()
// HTTP-Server starten
srv := &http.Server{
Addr: cfg.Host + ":" + cfg.Port,
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Printf("StreamDock gestartet auf %s:%s", cfg.Host, cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server-Fehler: %v", err)
}
}()
<-quit
log.Println("Server wird heruntergefahren...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Fehler beim Herunterfahren: %v", err)
}
log.Println("StreamDock beendet.")
}

37
docker-compose.yml Normal file
View File

@@ -0,0 +1,37 @@
# ============================================
# StreamDock - Docker Compose
# ============================================
services:
streamdock:
build: .
image: streamdock:latest
container_name: streamdock
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data/config:/app/data/config
- ./data/recordings:/app/data/recordings
- ./data/avatars:/app/data/avatars
env_file:
- .env
networks:
streamdock_net:
ipv4_address: 172.23.64.10
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/api/auth/me"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
streamdock_net:
name: streamdock.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.23.64.0/24
gateway: 172.23.64.1
ip_range: 172.23.64.128/25

51
docs/README.md Normal file
View File

@@ -0,0 +1,51 @@
# StreamDock Dokumentation
Willkommen zur Dokumentation von **StreamDock**, einem web-basierten Stream-Player und Recorder mit Benutzerverwaltung, Last.fm-Integration und zeitgesteuerter Aufnahme.
> **Hinweis:** StreamDock befindet sich aktuell im **Alpha-Stadium**. Funktionen können sich ändern, und es ist mit Fehlern zu rechnen.
---
## Inhaltsverzeichnis
| Dokument | Beschreibung |
|----------|-------------|
| [Installation & Schnellstart](installation.md) | Docker-Setup, erster Start, Voraussetzungen |
| [Konfiguration](konfiguration.md) | Umgebungsvariablen, .env-Datei, SMTP, Last.fm, Plik |
| [Architektur](architektur.md) | Projektstruktur, Technologie-Stack, Systemübersicht |
| [Features](features.md) | Alle Funktionen im Detail |
| [API-Referenz](api.md) | Sämtliche REST-Endpunkte mit Beschreibung |
| [Docker & Deployment](docker.md) | Dockerfile, Compose, Volumes, Netzwerk, Sicherheit |
| [Benutzerverwaltung](benutzerverwaltung.md) | Rollen, Rechte, Quotas, Admin-Panel |
| [Integrationen](integrationen.md) | Last.fm, Radio-Browser, Plik, Ntfy |
| [Sicherheit](sicherheit.md) | Authentifizierung, Empfehlungen, bekannte Einschränkungen |
| [Entwicklung](entwicklung.md) | Lokal bauen, testen, Makefile-Targets, Beitrag leisten |
---
## Über das Projekt
StreamDock ist eine selbst-gehostete Lösung für:
- **Audio- und Video-Streams** abspielen, verwalten und aufnehmen
- **Zeitgesteuerte Aufnahmen** per Cron-Ausdruck oder Einzeltermin
- **Last.fm Scrobbling** automatisches Melden des aktuellen Titels
- **Radio-Browser** Zugriff auf über 30.000 freie Radiosender
- **Benachrichtigungen** per E-Mail, Webhook oder Ntfy
- **PWA** als native App auf Smartphone/Desktop installierbar
### Tech-Stack
| Komponente | Technologie |
|-----------|-------------|
| Backend | Go 1.23+, Chi Router |
| Datenbank | SQLite (modernc.org/sqlite) |
| Frontend | Vanilla JS, Alpine.js, Web Audio API |
| Video | HLS.js |
| Auth | JWT + bcrypt |
| Scheduler | robfig/cron |
| Container | Docker, Alpine Linux |
---
*StreamDock Patrick Asmus [MIT License](../LICENSE)*

297
docs/api.md Normal file
View File

@@ -0,0 +1,297 @@
# API-Referenz
StreamDock bietet eine RESTful JSON-API. Alle geschützten Endpunkte erfordern ein gültiges JWT-Token.
## Authentifizierung
Geschützte Endpunkte erwarten eines der folgenden:
1. **Header:** `Authorization: Bearer <token>`
2. **Cookie:** `streamdock_token=<token>`
3. **Query-Parameter:** `?token=<token>` (Fallback für Streaming/WebSocket)
---
## Auth
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| POST | `/api/auth/login` | Nein | Anmeldung mit Benutzername & Passwort |
| GET | `/api/auth/me` | Ja | Eigenes Profil abrufen |
| PUT | `/api/auth/me` | Ja | Profil aktualisieren (Username, E-Mail) |
| PUT | `/api/auth/me/password` | Ja | Passwort ändern |
| POST | `/api/auth/me/avatar` | Ja | Profilbild hochladen (Multipart) |
### POST `/api/auth/login`
**Request:**
```json
{
"username": "admin",
"password": "geheim"
}
```
**Response (200):**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"role": "admin"
}
}
```
---
## Einstellungen
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/settings` | Ja | Benutzer-Einstellungen abrufen |
| PUT | `/api/settings` | Ja | Einstellungen aktualisieren |
| PUT | `/api/settings/volume` | Ja | Lautstärke speichern (0-100) |
---
## Streams
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/streams` | Ja | Alle Streams des Benutzers |
| POST | `/api/streams` | Ja | Neuen Stream hinzufügen |
| GET | `/api/streams/{id}` | Ja | Einzelnen Stream abrufen |
| PUT | `/api/streams/{id}` | Ja | Stream aktualisieren |
| DELETE | `/api/streams/{id}` | Ja | Stream löschen |
| POST | `/api/streams/{id}/check` | Ja | Stream-Verfügbarkeit prüfen |
| GET | `/api/streams/{id}/now-playing` | Ja | Aktuellen Titel abrufen (ICY-Metadata) |
| POST | `/api/streams/metadata` | Ja | Stream-Metadaten per URL parsen |
### POST `/api/streams`
**Request:**
```json
{
"name": "Mein Radiosender",
"url": "https://stream.example.com/live",
"stream_type": "audio"
}
```
### GET `/api/streams/{id}/now-playing`
**Response (200):**
```json
{
"artist": "Interpret",
"title": "Titel",
"album": ""
}
```
---
## Aufnahmen
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/recordings` | Ja | Alle Aufnahmen des Benutzers |
| POST | `/api/recordings/start` | Ja | Aufnahme starten |
| POST | `/api/recordings/{id}/stop` | Ja | Aufnahme stoppen |
| GET | `/api/recordings/{id}` | Ja | Aufnahme-Details |
| DELETE | `/api/recordings/{id}` | Ja | Aufnahme löschen |
| GET | `/api/recordings/{id}/download` | Ja | Aufnahme herunterladen |
| GET | `/api/recordings/{id}/stream` | Ja | Aufnahme abspielen (Streaming) |
| POST | `/api/recordings/{id}/share` | Ja | Share-Link erstellen |
| POST | `/api/recordings/{id}/plik` | Ja | An Plik-Server senden |
### POST `/api/recordings/start`
**Request:**
```json
{
"stream_id": 5
}
```
**Response (200):**
```json
{
"id": 42,
"status": "recording",
"file_name": "Mein_Radiosender_20260412_200000.mp3"
}
```
### POST `/api/recordings/{id}/share`
**Request:**
```json
{
"expires_hours": 72,
"max_downloads": 10
}
```
**Response (200):**
```json
{
"token": "abc123...",
"url": "http://localhost:8080/api/share/abc123.../download",
"expires_at": "2026-04-15T20:00:00Z"
}
```
---
## Zeitpläne
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/schedules` | Ja | Alle Zeitpläne des Benutzers |
| POST | `/api/schedules` | Ja | Neuen Zeitplan erstellen |
| PUT | `/api/schedules/{id}` | Ja | Zeitplan aktualisieren |
| DELETE | `/api/schedules/{id}` | Ja | Zeitplan löschen |
### POST `/api/schedules`
**Request (wiederkehrend):**
```json
{
"stream_id": 5,
"cron_expr": "0 0 20 * * 1-5",
"duration": 3600,
"is_recurring": true
}
```
**Request (einmalig):**
```json
{
"stream_id": 5,
"start_time": "2026-04-15T20:00:00Z",
"duration": 1800,
"is_recurring": false
}
```
---
## Bibliothek
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/library/search?q=rock` | Ja | Volltextsuche über Streams & Aufnahmen |
| GET | `/api/library/stats` | Ja | Bibliotheksstatistiken |
### GET `/api/library/stats`
**Response (200):**
```json
{
"total_streams": 12,
"total_recordings": 45,
"active_recordings": 1,
"storage_used": 1073741824
}
```
---
## Sharing (öffentlich)
Diese Endpunkte erfordern **keine Authentifizierung**, nur einen gültigen Share-Token.
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/share/{token}` | Nein | Metadaten der geteilten Aufnahme |
| GET | `/api/share/{token}/download` | Nein | Geteilte Aufnahme herunterladen |
| GET | `/api/share/{token}/stream` | Nein | Geteilte Aufnahme streamen |
---
## Radio-Browser
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/radio-browser/search?q=rock&country=DE` | Nein | Radiosender suchen |
**Query-Parameter:**
- `q` Suchbegriff (Name)
- `country` Länderfilter
- `tag` Genre/Tag-Filter
---
## Last.fm
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/lastfm/auth-url` | Ja | OAuth-URL für Last.fm generieren |
| POST | `/api/lastfm/callback` | Ja | OAuth-Token von Last.fm übernehmen |
| POST | `/api/lastfm/scrobble` | Ja | Titel an Last.fm senden |
---
## Video-Proxy
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| POST | `/api/proxy/start` | Ja | RTMP→HLS Konvertierung starten |
| POST | `/api/proxy/check` | Ja | Prüfen ob Stream Proxy benötigt |
| DELETE | `/api/proxy/{id}` | Ja | Proxy-Session beenden |
| GET | `/api/proxy/hls/{id}/{file}` | Ja | HLS-Segmente ausliefern |
---
## Admin
Diese Endpunkte erfordern die Rolle **admin**.
| Methode | Endpunkt | Auth | Beschreibung |
|---------|----------|------|-------------|
| GET | `/api/admin/users` | Admin | Alle Benutzer auflisten |
| POST | `/api/admin/users` | Admin | Neuen Benutzer erstellen |
| PUT | `/api/admin/users/{id}` | Admin | Benutzer bearbeiten |
| DELETE | `/api/admin/users/{id}` | Admin | Benutzer löschen (Kaskade) |
| PUT | `/api/admin/users/{id}/quota` | Admin | Speicher-Kontingent setzen |
### POST `/api/admin/users`
**Request:**
```json
{
"username": "neuer_user",
"email": "user@example.com",
"password": "sicheres-passwort",
"role": "user",
"storage_quota": 5368709120
}
```
---
## Fehler-Responses
Alle API-Fehler folgen diesem Format:
```json
{
"error": "Beschreibung des Fehlers"
}
```
| HTTP-Code | Bedeutung |
|-----------|----------|
| 400 | Ungültige Anfrage |
| 401 | Nicht authentifiziert |
| 403 | Keine Berechtigung |
| 404 | Nicht gefunden |
| 500 | Interner Serverfehler |
---
Weiter: [Docker & Deployment](docker.md)

275
docs/architektur.md Normal file
View File

@@ -0,0 +1,275 @@
# Architektur
## Systemübersicht
```
┌──────────────────────────────────────────────────┐
│ Docker Container │
│ ┌────────────────────────────────────────────┐ │
│ │ Alpine Linux 3.21 │ │
│ │ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ StreamDock (Go Binary) │ │ │
│ │ │ Benutzer: streamdock (non-root) │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────┐ ┌─────────────────┐ │ │ │
│ │ │ │ Chi │ │ Cron Scheduler │ │ │ │
│ │ │ │ Router │ │ (robfig/cron) │ │ │ │
│ │ │ └────┬─────┘ └────────┬────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ ┌────┴──────────────────┴────────┐ │ │ │
│ │ │ │ SQLite (WAL-Modus) │ │ │ │
│ │ │ └────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │
│ │ Volumes: │ │
│ │ /app/data/config (Datenbank) │ │
│ │ /app/data/recordings (Aufnahmen) │ │
│ │ /app/data/avatars (Profilbilder) │ │
│ └────────────────────────────────────────────┘ │
└───────────────────┬──────────────────────────────┘
│ :8080
┌─────┴──────┐
│ Reverse │ (nginx/traefik optional)
│ Proxy │
└─────┬──────┘
│ :443 (HTTPS)
┌─────┴──────┐
│ Browser │
│ (SPA) │
│ Alpine.js │
└────────────┘
```
---
## Projektstruktur
```
streamdock/
├── cmd/streamdock/ # Einstiegspunkt (main.go)
├── internal/ # Interne Pakete
│ ├── api/ # HTTP-Handler & Router-Definition
│ │ ├── handlers.go # Alle Endpoint-Handler
│ │ └── router.go # Route-Registrierung & Middleware
│ ├── auth/ # Authentifizierung
│ │ └── auth.go # JWT-Generierung, bcrypt-Hashing
│ ├── config/ # Konfiguration
│ │ └── config.go # Umgebungsvariablen laden
│ ├── db/ # Datenbankschicht
│ │ ├── db.go # Verbindung, Schema-Migration
│ │ └── queries.go # SQL-Queries (CRUD)
│ ├── lastfm/ # Last.fm API-Client
│ │ └── lastfm.go # OAuth, Scrobble, Now Playing
│ ├── middleware/ # HTTP-Middleware
│ │ └── middleware.go # Auth-Prüfung, Admin-Only
│ ├── models/ # Datenmodelle
│ │ └── models.go # Go-Structs für alle Entitäten
│ ├── notifications/ # Benachrichtigungen
│ │ └── notifications.go # E-Mail, Webhook, Ntfy
│ ├── plik/ # Plik API-Client
│ │ └── plik.go # Upload-Funktion
│ ├── radiobrowser/ # Radio-Browser API
│ │ └── radiobrowser.go # Sender-Suche, Top-Stationen
│ ├── recorder/ # Aufnahme-Engine
│ │ └── recorder.go # FFmpeg & HTTP-Fallback
│ ├── scheduler/ # Cron-Scheduler
│ │ └── scheduler.go # Zeitgesteuerte Jobs
│ ├── stream/ # Stream-Parser
│ │ └── parser.go # ICY-Metadata, Content-Erkennung
│ └── videoproxy/ # Video-Proxy
│ └── videoproxy.go # RTMP → HLS Konvertierung
├── web/ # Frontend
│ ├── static/
│ │ ├── css/style.css # Stylesheet (Dark Mode)
│ │ ├── js/app.js # Alpine.js Hauptanwendung
│ │ ├── js/player.js # Audio/Video-Player Klasse
│ │ └── img/ # Statische Bilder
│ ├── templates/
│ │ └── index.html # Single Page Application
│ └── pwa/
│ ├── manifest.json # PWA-Manifest
│ └── sw.js # Service Worker (Caching)
├── data/ # Persistente Daten (Docker Volumes)
│ ├── config/ # SQLite-Datenbank
│ ├── recordings/ # Aufgenommene Dateien
│ └── avatars/ # Benutzer-Profilbilder
├── tools/
│ └── genicon.go # Icon-Generator Tool
├── Dockerfile # Multi-Stage Docker Build
├── docker-compose.yml # Container-Orchestrierung
├── entrypoint.sh # Container-Startskript
├── Makefile # Build-Automatisierung
├── go.mod # Go-Modul & Abhängigkeiten
└── .env.example # Beispiel-Konfiguration
```
---
## Technologie-Stack
### Backend
| Komponente | Technologie | Zweck |
|-----------|-------------|-------|
| Sprache | Go 1.23+ | Kompilierte, typisierte Sprache |
| HTTP-Router | Chi v5 | Leichtgewichtiger REST-Router |
| Datenbank | SQLite + sqlx | Eingebettete DB, kein externer Dienst |
| Auth | JWT (golang-jwt) + bcrypt | Token-basierte Authentifizierung |
| Scheduler | robfig/cron v3 | Cron-Ausdrücke mit Sekunden-Präzision |
| SQLite-Treiber | modernc.org/sqlite | Pure Go, kein CGO nötig |
### Frontend
| Komponente | Technologie | Zweck |
|-----------|-------------|-------|
| Framework | Alpine.js | Reaktives UI ohne Build-Step |
| Player | Web Audio API | Equalizer, Visualizer |
| Video | HLS.js | HTTP Live Streaming |
| PWA | Service Worker | Offline-fähig, installierbar |
| Styling | Vanilla CSS | Dark Mode, responsive |
### Infrastruktur
| Komponente | Technologie | Zweck |
|-----------|-------------|-------|
| Container | Docker + Alpine 3.21 | Leichtgewichtige Laufzeitumgebung |
| Build | Multi-Stage Dockerfile | Kleine Imagesgröße (~30 MB) |
| Medien | FFmpeg | Aufnahme & Video-Konvertierung |
---
## Datenbank-Schema
StreamDock verwendet SQLite im WAL-Modus (Write-Ahead Logging) für optimale Performance bei gleichzeitigem Lesen und Schreiben.
### Tabellen
#### `users`
Benutzerverwaltung mit Authentifizierung.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | INTEGER (PK) | Auto-Increment |
| username | TEXT (UNIQUE) | Benutzername |
| email | TEXT (UNIQUE) | E-Mail-Adresse |
| password_hash | TEXT | bcrypt-Hash |
| role | TEXT | `admin` oder `user` |
| avatar_path | TEXT | Pfad zum Profilbild |
| storage_quota | INTEGER | Speicher-Kontingent in Bytes |
| storage_used | INTEGER | Aktuell genutzter Speicher |
| created_at, updated_at | DATETIME | Zeitstempel |
#### `user_settings`
Pro-Benutzer Einstellungen und Integrationen.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| user_id | INTEGER (FK) | Referenz auf users |
| lastfm_session_key | TEXT | Last.fm OAuth-Session |
| lastfm_username | TEXT | Last.fm-Benutzername |
| plik_server_url | TEXT | Plik-Server URL |
| plik_api_key | TEXT | Plik API-Key |
| notify_email, notify_webhook, notify_ntfy | BOOLEAN | Benachrichtigungskanäle |
| notify_on_login, notify_on_rec_* | BOOLEAN | Event-Trigger |
| volume | INTEGER | Lautstärke (0-100) |
#### `streams`
Gespeicherte Audio-/Video-Streams.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | INTEGER (PK) | Auto-Increment |
| user_id | INTEGER (FK) | Besitzer |
| name | TEXT | Anzeigename |
| url | TEXT | Stream-URL |
| stream_type | TEXT | `audio` oder `video` |
| content_type | TEXT | MIME-Type |
| bitrate, sample_rate | TEXT | Technische Metadaten |
| genre, description | TEXT | Beschreibende Metadaten |
| logo_url | TEXT | Sender-Logo |
| is_valid | BOOLEAN | Validierungsstatus |
| last_checked | DATETIME | Letzte Prüfung |
#### `recordings`
Aufgenommene Dateien.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | INTEGER (PK) | Auto-Increment |
| user_id | INTEGER (FK) | Besitzer |
| stream_id | INTEGER (FK) | Quell-Stream |
| file_path, file_name | TEXT | Datei-Informationen |
| file_size | INTEGER | Größe in Bytes |
| duration | INTEGER | Dauer in Sekunden |
| format | TEXT | Dateiformat |
| status | TEXT | `recording`, `completed`, `error` |
| share_token | TEXT | Token für Share-Link |
| started_at, stopped_at | DATETIME | Aufnahme-Zeitraum |
#### `scheduled_recordings`
Zeitgesteuerte Aufnahme-Jobs.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| id | INTEGER (PK) | Auto-Increment |
| user_id | INTEGER (FK) | Besitzer |
| stream_id | INTEGER (FK) | Ziel-Stream |
| cron_expr | TEXT | Cron-Ausdruck (6 Felder) |
| start_time | DATETIME | Nächster Startzeitpunkt |
| duration | INTEGER | Aufnahmedauer in Sekunden |
| is_recurring | BOOLEAN | Wiederkehrend ja/nein |
| is_active | BOOLEAN | Aktiv ja/nein |
#### `share_links`
Öffentliche Freigabe-Links für Aufnahmen.
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| token | TEXT (PK) | Eindeutiger Freigabe-Token |
| expires_at | DATETIME | Ablaufzeitpunkt |
| max_downloads | INTEGER | Max. Anzahl Downloads |
| downloads | INTEGER | Bisherige Downloads |
---
## Datenflüsse
### Authentifizierung
```
Browser → POST /api/auth/login (user + pass)
→ Server: bcrypt.Compare → JWT generieren
← JWT Token
Browser → Jeder Request: Authorization: Bearer <token>
→ Middleware: JWT validieren → User-Context setzen
```
### Stream-Aufnahme
```
Benutzer → POST /api/recordings/start (stream_id)
→ Recorder: FFmpeg starten (oder HTTP-Fallback)
→ Goroutine schreibt Datei
→ POST /api/recordings/{id}/stop
→ FFmpeg-Prozess beenden → Status: completed
```
### Geplante Aufnahme
```
Scheduler (Cron) → Trigger um konfigurierte Zeit
→ executeScheduledRecording(id)
→ Recorder.Start()
→ Timer: Nach {duration} Sekunden → Stop
→ Nächsten Cron-Lauf berechnen
```
---
Weiter: [Features](features.md)

114
docs/benutzerverwaltung.md Normal file
View File

@@ -0,0 +1,114 @@
# Benutzerverwaltung
## Rollen
StreamDock kennt zwei Benutzerrollen:
| Rolle | Beschreibung |
|-------|-------------|
| **admin** | Vollzugriff inkl. Benutzerverwaltung, Quotas und Systemeinstellungen |
| **user** | Zugriff auf eigene Streams, Aufnahmen, Zeitpläne und Einstellungen |
---
## Initialer Admin-Account
Beim ersten Start wird automatisch ein Admin-Account erstellt:
| Eigenschaft | Standard | Umgebungsvariable |
|------------|---------|-------------------|
| Benutzername | `admin` | `STREAMDOCK_ADMIN_USER` |
| Passwort | `admin` | `STREAMDOCK_ADMIN_PASSWORD` |
| E-Mail | `admin@example.com` | `STREAMDOCK_ADMIN_EMAIL` |
> **Wichtig:** Das Standard-Passwort muss sofort nach dem ersten Login geändert werden.
---
## Admin-Panel
Admins haben Zugriff auf das Benutzer-Management unter dem Menüpunkt **Admin**.
### Benutzer erstellen
- Benutzername (eindeutig)
- E-Mail (eindeutig)
- Passwort
- Rolle (admin/user)
- Speicher-Kontingent (in Bytes)
### Benutzer bearbeiten
- Benutzername, E-Mail, Rolle ändern
- Passwort zurücksetzen
- Speicher-Kontingent anpassen
### Benutzer löschen
Beim Löschen eines Benutzers werden **kaskadierend** entfernt:
- Alle Streams des Benutzers
- Alle Aufnahmen (inkl. Dateien auf der Festplatte)
- Alle Zeitpläne
- Alle Einstellungen
- Share-Links
---
## Speicher-Kontingent (Quota)
Jeder Benutzer hat ein konfigurierbares Speicherlimit:
- **Standard:** Wird vom Admin beim Erstellen festgelegt
- **Überwachung:** Genutzer Speicher wird bei jeder Aufnahme aktualisiert
- **Durchsetzung:** Neue Aufnahmen werden abgelehnt, wenn das Kontingent erschöpft ist
- **Anzeige:** Benutzer sehen ihren genutzten Speicher im Dashboard
### Kontingent-Werte (Beispiele)
| Kontingent | Wert in Bytes |
|-----------|--------------|
| 1 GB | `1073741824` |
| 5 GB | `5368709120` |
| 10 GB | `10737418240` |
| Unbegrenzt | `0` |
---
## Profil-Einstellungen
Jeder Benutzer kann folgende Einstellungen selbst verwalten:
### Profil
- Benutzername ändern
- E-Mail ändern
- Profilbild hochladen (Avatar)
- Passwort ändern
### Integrationen
- **Last.fm** OAuth-Verbindung herstellen/trennen
- **Plik** Server-URL und API-Key konfigurieren
### Benachrichtigungen
- **E-Mail:** Aktivieren/Deaktivieren + Adresse
- **Webhook:** URL für HTTP POST Callbacks
- **Ntfy:** Server-URL + Topic konfigurieren
- **Events:** Individuell pro Kanal wählbar (Login, Aufnahme-Start/Ende/Fehler)
### Player
- Lautstärke wird automatisch pro Benutzer gespeichert
- EQ-Einstellungen werden lokal im Browser gespeichert
---
## Datenisolierung
StreamDock stellt sicher, dass Benutzer nur auf ihre eigenen Daten zugreifen:
- Alle Datenbank-Abfragen filtern nach `user_id`
- Streams, Aufnahmen und Zeitpläne sind benutzerspezifisch
- Share-Links ermöglichen kontrollierten öffentlichen Zugriff
- Admin-Endpunkte sind durch Rollen-Middleware geschützt
---
Weiter: [Integrationen](integrationen.md)

239
docs/docker.md Normal file
View File

@@ -0,0 +1,239 @@
# Docker & Deployment
## Docker-Image
StreamDock verwendet einen **Multi-Stage Build** für ein schlankes Produktions-Image.
### Build-Stufen
**1. Build Stage (`golang:1.23-alpine`)**
- Kompiliert das Go-Binary mit `CGO_ENABLED=0` (statisch gelinkt, keine C-Abhängigkeiten)
- Flags: `-ldflags="-s -w"` (ohne Debug-Symbole, kleineres Binary)
**2. Runtime Stage (`alpine:3.21`)**
- Installierte Pakete: `ca-certificates`, `tzdata`, `ffmpeg`, `su-exec`
- Image-Größe: ca. 30 MB
- Enthält: Go-Binary, Web-Assets, Entrypoint-Script
---
## Container-Benutzer & Sicherheit
### Prozess-Benutzer
Der Container erstellt einen dedizierten Benutzer `streamdock` (ohne Root-Rechte):
```dockerfile
RUN adduser -D -h /app streamdock
```
Das Entrypoint-Script (`entrypoint.sh`) führt folgende Schritte aus:
1. Setzt Besitzrechte auf die Daten-Verzeichnisse (nötig, da Volumes als root gemountet werden können)
2. Startet die Anwendung als `streamdock`-Benutzer via `su-exec`
```sh
#!/bin/sh
chown -R streamdock:streamdock /app/data
exec su-exec streamdock ./streamdock "$@"
```
> **Fazit:** Die Anwendung selbst läuft als **Non-Root-Benutzer** innerhalb des Containers. Der Container-Einstiegspunkt benötigt kurzzeitig Root-Rechte für das Setzen der Volume-Berechtigungen.
### Rootless Docker
> **Hinweis:** StreamDock verwendet aktuell **kein Rootless Docker**. Der Docker-Daemon läuft weiterhin mit Root-Rechten. Das ist ein separates Thema und betrifft die Docker-Installation auf dem Host, nicht den Container selbst.
>
> Eine Migration zu [Rootless Docker](https://docs.docker.com/engine/security/rootless/) kann die Sicherheit weiter erhöhen, indem auch der Docker-Daemon ohne Root-Privilegien betrieben wird. Dies ist insbesondere für öffentlich erreichbare Systeme empfehlenswert und steht als zukünftige Verbesserung auf der Roadmap.
---
## docker-compose.yml
```yaml
services:
streamdock:
build: .
image: streamdock:latest
container_name: streamdock
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data/config:/app/data/config
- ./data/recordings:/app/data/recordings
- ./data/avatars:/app/data/avatars
env_file:
- .env
networks:
streamdock_net:
ipv4_address: 172.23.64.10
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/api/auth/me"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
streamdock_net:
name: streamdock.dockernetwork.local
driver: bridge
ipam:
config:
- subnet: 172.23.64.0/24
gateway: 172.23.64.1
ip_range: 172.23.64.128/25
```
---
## Volumes
| Container-Pfad | Host-Pfad | Beschreibung |
|----------------|-----------|-------------|
| `/app/data/config` | `./data/config` | SQLite-Datenbank (`streamdock.db`) |
| `/app/data/recordings` | `./data/recordings` | Aufgenommene Audio-/Video-Dateien |
| `/app/data/avatars` | `./data/avatars` | Benutzer-Profilbilder |
> **Wichtig:** Die Volumes bleiben bei Container-Neustart/Update erhalten. Ein Backup der `data/`-Verzeichnisse sichert den kompletten Anwendungszustand.
---
## Health Check
Der Container prüft regelmäßig seine eigene Erreichbarkeit:
```
GET http://localhost:8080/api/auth/me
```
| Parameter | Wert |
|-----------|------|
| Intervall | 30 Sekunden |
| Timeout | 5 Sekunden |
| Retries | 3 |
| Start-Verzögerung | 10 Sekunden |
---
## Netzwerk
StreamDock erstellt ein eigenes Bridge-Netzwerk:
| Eigenschaft | Wert |
|------------|------|
| Name | `streamdock.dockernetwork.local` |
| Treiber | `bridge` |
| Subnetz | `172.23.64.0/24` |
| Gateway | `172.23.64.1` |
| Container-IP | `172.23.64.10` |
---
## Deployment mit Reverse Proxy
Für den produktionsnahen Betrieb wird ein Reverse Proxy mit HTTPS empfohlen.
### Beispiel: nginx
```nginx
server {
listen 443 ssl;
server_name streamdock.example.com;
ssl_certificate /etc/letsencrypt/live/streamdock.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/streamdock.example.com/privkey.pem;
location / {
proxy_pass http://172.23.64.10:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Für Streaming/große Dateien
proxy_buffering off;
proxy_read_timeout 300s;
}
}
```
### Beispiel: Traefik (Labels)
```yaml
services:
streamdock:
labels:
- "traefik.enable=true"
- "traefik.http.routers.streamdock.rule=Host(`streamdock.example.com`)"
- "traefik.http.routers.streamdock.tls.certresolver=letsencrypt"
- "traefik.http.services.streamdock.loadbalancer.server.port=8080"
```
---
## Befehle
### Container starten
```bash
docker compose up -d
```
### Container stoppen
```bash
docker compose down
```
### Logs ansehen
```bash
docker compose logs -f streamdock
```
### Neu bauen (nach Update)
```bash
docker compose build --no-cache
docker compose up -d
```
### Datenbank zurücksetzen
```bash
# Container stoppen
docker compose down
# Datenbank löschen
rm data/config/streamdock.db
# Neu starten (DB wird automatisch erstellt)
docker compose up -d
```
---
## Backup
```bash
# Komplettes Backup der Anwendungsdaten
tar -czf streamdock-backup-$(date +%Y%m%d).tar.gz data/
# Nur Datenbank sichern
cp data/config/streamdock.db streamdock-backup-$(date +%Y%m%d).db
```
---
## Umgebungsvariablen im Container
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_HOST` | `0.0.0.0` | Bind-Adresse |
| `STREAMDOCK_PORT` | `8080` | Lausch-Port |
| `STREAMDOCK_DB_PATH` | `/app/data/config/streamdock.db` | Datenbank-Pfad |
| `STREAMDOCK_RECORDINGS_PATH` | `/app/data/recordings` | Aufnahme-Pfad |
| `STREAMDOCK_AVATARS_PATH` | `/app/data/avatars` | Avatar-Pfad |
Alle weiteren Variablen: siehe [Konfiguration](konfiguration.md).
---
Weiter: [Benutzerverwaltung](benutzerverwaltung.md)

204
docs/entwicklung.md Normal file
View File

@@ -0,0 +1,204 @@
# Entwicklung
## Voraussetzungen
| Werkzeug | Version | Pflicht |
|----------|---------|---------|
| Go | 1.23+ | Ja |
| FFmpeg | Aktuell | Optional (für Aufnahmen & Video-Proxy) |
| Docker | Aktuell | Für Container-Build |
| Make | Aktuell | Für Build-Automatisierung |
---
## Projekt einrichten
```bash
# Repository klonen
git clone <repo-url> streamdock
cd streamdock
# Abhängigkeiten herunterladen
go mod download
# Oder:
make deps
```
---
## Makefile-Targets
| Target | Beschreibung |
|--------|-------------|
| `make build` | Go-Binary kompilieren |
| `make run` | Bauen und lokal ausführen |
| `make deps` | Abhängigkeiten herunterladen |
| `make test` | Unit-Tests ausführen |
| `make lint` | Code-Analyse mit golangci-lint |
| `make clean` | Binary entfernen, Datenbank zurücksetzen |
| `make docker` | Docker-Image bauen |
| `make docker-run` | Container mit docker-compose starten |
| `make docker-stop` | Container stoppen |
| `make docker-logs` | Container-Logs verfolgen |
| `make db-reset` | Datenbank löschen und neu erstellen |
---
## Lokal starten
```bash
# Direkt mit Go
go run ./cmd/streamdock
# Mit Make
make run
# Mit angepasster Konfiguration
STREAMDOCK_PORT=3000 STREAMDOCK_JWT_SECRET=dev-secret go run ./cmd/streamdock
```
Die Anwendung ist standardmäßig unter `http://localhost:8080` erreichbar.
---
## Tests ausführen
```bash
# Alle Tests
make test
# Oder direkt:
go test ./...
# Mit Verbose-Output
go test -v ./...
# Einzelnes Paket testen
go test -v ./internal/auth/
go test -v ./internal/db/
go test -v ./internal/recorder/
```
### Vorhandene Tests
| Paket | Datei | Beschreibung |
|-------|-------|-------------|
| `internal/auth` | `auth_test.go` | JWT-Generierung, Passwort-Hashing |
| `internal/db` | `db_test.go` | Datenbankoperationen, Schema |
| `internal/recorder` | `recorder_test.go` | Aufnahme-Logik |
---
## Code-Analyse
```bash
# Mit golangci-lint
make lint
# Oder manuell:
golangci-lint run ./...
```
---
## Docker-Image lokal bauen
```bash
# Image bauen
make docker
# Oder:
docker compose build
# Ohne Cache (nach Dependency-Änderungen)
docker compose build --no-cache
# Container starten
make docker-run
# Oder:
docker compose up -d
# Logs verfolgen
make docker-logs
# Oder:
docker compose logs -f
```
---
## Projektstruktur-Konventionen
### Go-Code
- **`cmd/`** Einstiegspunkte (main-Packages)
- **`internal/`** Interne Pakete (nicht von außen importierbar)
- Ein Paket pro Verantwortungsbereich
- `models/` enthält nur Datenstrukturen, keine Logik
- `api/` enthält Handler und Router
- **Keine globalen Variablen** Dependency Injection über Structs
### Frontend
- **Kein Build-Step** Vanilla JS + Alpine.js (direkt auslieferbar)
- **Single Page Application** Eine `index.html`, Routing über Alpine.js State
- **CSS-Variablen** Design-Tokens in `:root`
### Datenbank
- **Schema-Migration** Automatisch beim Start (`db.go`)
- **Parametrisierte Queries** Kein String-Concatenation
- **Benannte Queries** `sqlx` Tags in Structs
---
## Neue Funktionen hinzufügen
### Neuen API-Endpunkt
1. **Model definieren** in `internal/models/models.go`
2. **Query schreiben** in `internal/db/queries.go`
3. **Handler erstellen** in `internal/api/handlers.go`
4. **Route registrieren** in `internal/api/router.go`
5. **Frontend anbinden** in `web/static/js/app.js`
### Neue Integration
1. **Client-Paket** erstellen unter `internal/{integration}/`
2. **Konfiguration** in `internal/config/config.go` ergänzen
3. **API-Endpunkte** hinzufügen
4. **Benutzer-Einstellungen** in der DB erweitern (Schema-Migration)
---
## Deployment-Workflow
```bash
# Lokale Änderungen auf den Server übertragen und deployen
scp -r ./* .env entrypoint.sh user@server:/pfad/zum/projekt/
ssh user@server "cd /pfad/zum/projekt && docker compose down && docker compose build --no-cache && docker compose up -d"
```
---
## Abhängigkeiten (go.mod)
### Direkte Abhängigkeiten
| Modul | Zweck |
|-------|-------|
| `github.com/go-chi/chi/v5` | HTTP-Router |
| `github.com/go-chi/cors` | CORS-Middleware |
| `github.com/golang-jwt/jwt/v5` | JWT-Authentifizierung |
| `github.com/jmoiron/sqlx` | SQL-Datenbank-Wrapper |
| `github.com/robfig/cron/v3` | Cron-Scheduler |
| `golang.org/x/crypto` | bcrypt Passwort-Hashing |
| `modernc.org/sqlite` | Pure-Go SQLite-Treiber |
### Wichtige Charakteristiken
- **Kein CGO** Reine Go-Kompilierung, maximale Portabilität
- **Minimale Abhängigkeiten** Leichtgewichtiger Dependency-Tree
- **Keine ORM** Direkte SQL-Queries via sqlx
---
Zurück zur [Dokumentationsübersicht](README.md)

160
docs/features.md Normal file
View File

@@ -0,0 +1,160 @@
# Features
Eine Übersicht aller Funktionen von StreamDock.
---
## Stream-Verwaltung
- **Streams hinzufügen** Manuelle Eingabe per URL mit automatischer Erkennung von Typ und Metadaten
- **Stream-Validierung** Prüfung der Erreichbarkeit mit Auslesen von ICY-Metadaten (Sendername, Genre, Codec, Bitrate)
- **Unterstützte Formate:**
- Audio: MP3, AAC, OGG, FLAC
- Video: HLS (HTTP Live Streaming), HTTP-Streams
- **Automatische Typ-Erkennung** Unterscheidung zwischen Audio und Video anhand von Content-Type und URL-Pattern
- **Bearbeiten & Löschen** Vollständige CRUD-Verwaltung pro Benutzer
---
## Web-Player
### Audio-Wiedergabe
- HTML5 Audio-Element mit Web Audio API
- **7-Band Equalizer** mit einstellbaren Frequenzen:
- 60 Hz, 170 Hz, 350 Hz, 1 kHz, 3.5 kHz, 10 kHz, 16 kHz
- **EQ-Presets:** Flat, Bass, Rock, Vocal
- **Echtzeit-Visualizer** FFT-basierte Frequenzspektrum-Anzeige (optonal ein-/ausschaltbar)
- **Lautstärkeregelung** 0-100%, Scroll-Rad Unterstützung, automatische Speicherung pro Benutzer
### Video-Wiedergabe
- HLS.js-basierte Wiedergabe von HTTP Live Streams
- Video-Proxy für RTMP-Streams (automatische Konvertierung zu HLS via FFmpeg)
### Media Session
- Integration mit dem Betriebssystem-Medienplayer (Play/Pause/Stop über Systemsteuerung)
- Anzeige von Stream-Name und Titel in der Systembenachrichtigung
### Now Playing
- Automatisches Polling der ICY-Metadaten (alle 30 Sekunden)
- Anzeige des aktuellen Titels (Interpret Titel) im Player
---
## Aufnahme-System
### Manuelle Aufnahme
- Sofort-Aufnahme jedes Streams per Knopfdruck
- Start/Stop über die Web-Oberfläche
- Status-Anzeige: aufnehmend (pulsierend), abgeschlossen, Fehler
### Aufnahme-Engines
1. **FFmpeg (bevorzugt):** Codec-Copy-Modus schnell, verlustfrei, formatübergreifend
2. **HTTP-Fallback:** Direkter Stream-Download, falls FFmpeg nicht verfügbar
### Datei-Organisation
```
{recordings_path}/{benutzername}/{stream_name}/{dateiname}_{zeitstempel}.mp3
```
- Automatische Bereinigung von Sonderzeichen in Dateinamen
- Maximale Verzeichnisnamenlänge: 100 Zeichen
### Aufnahmen verwalten
- **Abspielen** Direkt im Browser
- **Herunterladen** Als Datei speichern
- **Teilen** Öffentlicher Share-Link mit optionalem Ablaufdatum und Download-Limit
- **Plik-Upload** Aufnahme an Plik-Server senden (mit Download-Link)
- **Löschen** Datei und Datenbankeintrag entfernen
---
## Zeitgesteuerte Aufnahmen
### Wiederkehrende Aufnahmen
- **Cron-Ausdrücke** mit 6 Feldern (inkl. Sekunden):
- Beispiel: `0 0 20 * * 1-5` → Montag bis Freitag um 20:00 Uhr
- Beispiel: `0 30 8 * * 0` → Sonntag um 08:30 Uhr
- Automatische Berechnung des nächsten Ausführungszeitpunkts
- Aktivierung/Deaktivierung einzelner Zeitpläne
### Einmalige Aufnahmen
- Aufnahme zu einem bestimmten Datum und Uhrzeit
- Automatischer Stopp nach konfigurierter Dauer
### Aufnahme-Ablauf
1. Scheduler löst Job zum konfigurierten Zeitpunkt aus
2. Aufnahme startet automatisch
3. Nach Ablauf der eingestellten Dauer: automatischer Stopp
4. Nächster Cron-Lauf wird berechnet
5. Optional: Benachrichtigung bei Start/Ende/Fehler
---
## Bibliothek & Suche
- **Volltextsuche** über Streams (Name, Genre, Beschreibung) und Aufnahmen (Dateiname)
- **Statistiken:** Anzahl Streams, Aufnahmen, aktive Aufnahmen, genutzter Speicher
- **Benutzerisolierung** Jeder Benutzer sieht nur seine eigenen Daten
---
## Radio-Browser
- Zugriff auf die **radio-browser.info API** mit über 30.000 freien Radiosendern
- **Suche** nach Name, Land oder Genre-Tag
- **Top-Stationen** nach Beliebtheit sortiert
- **Ein-Klick-Hinzufügen** Sender direkt in die eigene Stream-Bibliothek übernehmen
- Metadaten: Codec, Bitrate, Stimmen, Favicon
---
## Benachrichtigungen
### Kanäle
| Kanal | Beschreibung |
|-------|-------------|
| **E-Mail** | Versand über konfigurierten SMTP-Server |
| **Webhook** | HTTP POST mit JSON-Payload an beliebige URL |
| **Ntfy** | Push-Benachrichtigungen über ntfy.sh oder eigene Instanz |
### Events
| Event | Beschreibung |
|-------|-------------|
| Login | Benachrichtigung bei erfolgreicher Anmeldung |
| Aufnahme gestartet | Wenn eine Aufnahme beginnt |
| Aufnahme abgeschlossen | Wenn eine Aufnahme erfolgreich beendet wird |
| Aufnahme-Fehler | Wenn eine Aufnahme fehlschlägt |
Jeder Benutzer kann individuell wählen, welche Kanäle für welche Events aktiv sein sollen.
---
## Progressive Web App (PWA)
- **Installierbar** auf Smartphone, Tablet und Desktop
- **Offline-fähig** Service Worker mit Cache-first Strategie für statische Assets
- **Standalone-Modus** Läuft wie eine native App (ohne Browser-Leiste)
- **Automatisches Cache-Update** bei neuen Versionen
- **Sprache:** Deutsch
---
## Dashboard
- Übersichtliche Statistiken:
- Anzahl gespeicherter Streams
- Anzahl Aufnahmen
- Aktive Aufnahmen
- Genutzter Speicherplatz
- Schnellzugriff auf alle Bereiche
---
## Responsive Design
- Optimiert für **Desktop**, **Tablet** und **Smartphone**
- Dark-Mode Oberfläche
- Responsive Navigation mit Toggle-Menü auf kleinen Bildschirmen
---
Weiter: [API-Referenz](api.md)

126
docs/installation.md Normal file
View File

@@ -0,0 +1,126 @@
# Installation & Schnellstart
## Voraussetzungen
- **Docker** und **Docker Compose** (empfohlen)
- Alternativ: Go 1.23+ für lokale Entwicklung
- Optional: FFmpeg (für schnelle Aufnahmen und Video-Proxy)
---
## Docker Compose (empfohlen)
### 1. Repository klonen
```bash
git clone <repo-url> streamdock
cd streamdock
```
### 2. Umgebungsvariablen konfigurieren
```bash
cp .env.example .env
```
Öffne die `.env`-Datei und passe mindestens folgende Werte an:
```env
# WICHTIG: Unbedingt ändern!
STREAMDOCK_JWT_SECRET=dein-sicherer-zufallsstring-min-32-zeichen
STREAMDOCK_ADMIN_PASSWORD=dein-sicheres-passwort
# Optionale Anpassungen
STREAMDOCK_BASE_URL=http://localhost:8080
STREAMDOCK_ADMIN_USER=admin
STREAMDOCK_ADMIN_EMAIL=admin@example.com
```
> **Sicherheitshinweis:** Verwende keinesfalls die Standard-Werte für `JWT_SECRET` und `ADMIN_PASSWORD` in einer produktionsnahen Umgebung.
### 3. Container starten
```bash
docker compose up -d
```
### 4. Erster Login
Öffne im Browser: **http://localhost:8080**
- **Benutzername:** `admin` (oder dein konfigurierter Wert)
- **Passwort:** `admin` (oder dein konfigurierter Wert)
> Ändere das Standard-Passwort direkt nach dem ersten Login unter **Einstellungen → Passwort ändern**.
---
## Lokal entwickeln (ohne Docker)
### Voraussetzungen
- Go 1.23 oder neuer
- Optional: FFmpeg im PATH
### Starten
```bash
# Abhängigkeiten herunterladen
go mod download
# Direkt ausführen
go run ./cmd/streamdock
# Oder mit Make:
make deps
make run
```
Die Anwendung ist standardmäßig unter `http://localhost:8080` erreichbar.
---
## Verzeichnisstruktur nach dem Start
Nach dem ersten Start werden folgende Daten-Verzeichnisse automatisch erstellt:
```
data/
├── config/ # SQLite-Datenbank (streamdock.db)
├── recordings/ # Aufgenommene Audio-/Video-Dateien
└── avatars/ # Benutzer-Profilbilder
```
Diese Verzeichnisse sind als Docker-Volumes gemountet und bleiben bei Container-Neustart erhalten.
---
## Update
```bash
# Neueste Version holen
git pull
# Container neu bauen und starten
docker compose down
docker compose build --no-cache
docker compose up -d
```
> Die SQLite-Datenbank und alle Aufnahmen bleiben durch die Volumes erhalten.
---
## Deinstallation
```bash
# Container stoppen und entfernen
docker compose down
# Optional: Daten löschen
rm -rf data/
```
---
Weiter: [Konfiguration](konfiguration.md)

228
docs/integrationen.md Normal file
View File

@@ -0,0 +1,228 @@
# Integrationen
StreamDock bietet Integrationen mit verschiedenen externen Diensten.
---
## Last.fm
### Überblick
Last.fm Scrobbling ermöglicht es, gehörte Titel automatisch im eigenen Last.fm-Profil zu protokollieren.
### Voraussetzungen
1. **Last.fm Account** unter https://www.last.fm
2. **API-Anwendung erstellen** unter https://www.last.fm/api/account/create
3. API-Key und API-Secret in der `.env` konfigurieren:
```env
STREAMDOCK_LASTFM_API_KEY=dein-api-key
STREAMDOCK_LASTFM_API_SECRET=dein-api-secret
```
### Verbindung herstellen
1. In StreamDock: **Einstellungen → Last.fm → Verbinden**
2. Weiterleitung zur Last.fm Autorisierungsseite
3. Zugriff erlauben → Rückleitung zu StreamDock
4. Session-Key wird automatisch gespeichert
### Funktionsweise
- **Now Playing:** Beim Start der Wiedergabe wird der aktuelle Titel an Last.fm gemeldet
- **Scrobble:** Nach 10 Sekunden Wiedergabe wird der Titel offiziell gescrobblet
- **Voraussetzung:** ICY-Metadaten müssen Interpret und Titel enthalten
- **Stilles Scheitern:** Wenn keine Metadaten verfügbar sind, wird kein Fehler geworfen
### Technische Details
- Authentifizierung über Last.fm OAuth 2.0
- API-Signierung via MD5-Hash (Vorgabe von Last.fm)
- Session-Key wird in `user_settings` gespeichert
---
## Radio-Browser
### Überblick
Zugriff auf die freie radio-browser.info Datenbank mit über 30.000 Radiosendern weltweit.
### Nutzung
1. Im Menü: **Radio-Browser**
2. Suche nach Name, Land oder Genre-Tag
3. Ergebnisse zeigen: Sendername, Land, Codec, Bitrate, Beliebtheit
4. **Hinzufügen-Button** übernimmt den Sender direkt in die eigene Stream-Bibliothek
### Features
- Suche nach Name, Land, Sprache, Tag
- Sortierung nach Beliebtheit (Stimmen)
- Automatische Filterung defekter Sender
- Sender-Metadaten: Favicon, Codec, Bitrate, Votes
### Technische Details
- API-Endpunkt: `de1.api.radio-browser.info`
- Keine Authentifizierung erforderlich
- Ergebnisse werden nicht gecacht (immer aktuell)
---
## Plik
### Überblick
[Plik](https://github.com/root-gg/plik) ist ein selbst-gehosteter Datei-Sharing-Dienst. StreamDock kann Aufnahmen direkt an eine Plik-Instanz hochladen.
### Voraussetzungen
1. Laufende Plik-Instanz (selbst-gehostet)
2. Plik API-Key
### Konfiguration
In den **Benutzer-Einstellungen**:
- **Plik Server URL:** `https://plik.example.com`
- **Plik API Key:** `dein-api-key`
Alternativ global über Umgebungsvariablen:
```env
STREAMDOCK_PLIK_URL=https://plik.example.com
STREAMDOCK_PLIK_API_KEY=dein-api-key
```
### Nutzung
1. Aufnahme auswählen → **An Plik senden**
2. Datei wird automatisch per Multipart-Upload übertragen
3. Download-URL wird zurückgegeben und angezeigt
### Technische Details
- Upload-TTL: 7 Tage (Standard)
- One-Shot: Deaktiviert (mehrfacher Download möglich)
- Authentifizierung via `X-Plik-Token` Header
---
## Ntfy
### Überblick
[Ntfy](https://ntfy.sh) ist ein einfacher Push-Benachrichtigungsdienst. StreamDock kann Benachrichtigungen an ein Ntfy-Topic senden.
### Konfiguration
In den **Benutzer-Einstellungen**:
- **Ntfy aktivieren:** Checkbox
- **Ntfy Server:** Standard `https://ntfy.sh` (oder eigene Instanz)
- **Ntfy Topic:** Frei wählbarer Topic-Name
Global den Standard-Server festlegen:
```env
STREAMDOCK_NTFY_DEFAULT_SERVER=https://ntfy.sh
```
### Events
- Login-Benachrichtigung
- Aufnahme gestartet
- Aufnahme abgeschlossen
- Aufnahme-Fehler
### Nutzung
1. Ntfy-App auf Smartphone installieren (Android/iOS)
2. Topic abonnieren
3. StreamDock sendet automatisch Benachrichtigungen bei konfigurierten Events
---
## E-Mail (SMTP)
### Überblick
StreamDock kann Benachrichtigungen per E-Mail über einen SMTP-Server versenden.
### Konfiguration
In der `.env`-Datei:
```env
STREAMDOCK_SMTP_HOST=smtp.example.com
STREAMDOCK_SMTP_PORT=587
STREAMDOCK_SMTP_USER=user@example.com
STREAMDOCK_SMTP_PASSWORD=smtp-passwort
STREAMDOCK_SMTP_FROM=noreply@example.com
STREAMDOCK_SMTP_FROM_NAME=StreamDock
```
### Aktivierung pro Benutzer
In den **Benutzer-Einstellungen**:
- E-Mail-Benachrichtigungen aktivieren
- Empfänger-Adresse eintragen
- Events auswählen (Login, Aufnahme-Start/Ende/Fehler)
---
## Webhook
### Überblick
StreamDock kann HTTP POST Requests mit JSON-Payload an eine beliebige URL senden.
### Konfiguration
In den **Benutzer-Einstellungen**:
- Webhook aktivieren
- Webhook-URL eintragen
- Events auswählen
### Payload-Format
```json
{
"event": "recording_completed",
"user": "benutzername",
"message": "Aufnahme 'Mein Stream' wurde abgeschlossen",
"timestamp": "2026-04-12T20:00:00Z"
}
```
### Einsatzbeispiele
- Integration mit Discord/Slack über Webhook-URLs
- Automatisierung mit n8n, Node-RED oder Zapier
- Eigene Monitoring-Systeme
---
## Video-Proxy (RTMP → HLS)
### Überblick
Für RTMP-Streams, die der Browser nicht direkt abspielen kann, bietet StreamDock einen integrierten Video-Proxy, der RTMP in HLS (HTTP Live Streaming) konvertiert.
### Voraussetzungen
- **FFmpeg** muss im Container verfügbar sein (standardmäßig installiert)
### Funktionsweise
1. StreamDock erkennt automatisch, ob ein Stream einen Proxy benötigt
2. FFmpeg konvertiert den RTMP-Stream in HLS-Segmente
3. Der Browser ruft die HLS-Segmente über den internen Proxy ab
4. **HLS.js** übernimmt die Wiedergabe im Browser
### Technische Details
- FFmpeg-Konfiguration: `-c:v copy -c:a aac -b:a 128k -hls_time 4`
- Sliding Window: 10 Segmente
- Startwartezeit: bis zu 15 Sekunden für initiale HLS-Playlist
- Session-basiert: Automatische Bereinigung bei Beenden
---
Weiter: [Sicherheit](sicherheit.md)

119
docs/konfiguration.md Normal file
View File

@@ -0,0 +1,119 @@
# Konfiguration
StreamDock wird vollständig über **Umgebungsvariablen** konfiguriert. Diese können in einer `.env`-Datei definiert werden, die von Docker Compose automatisch geladen wird.
---
## Übersicht aller Umgebungsvariablen
### Server
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_HOST` | `0.0.0.0` | Bind-Adresse des HTTP-Servers |
| `STREAMDOCK_PORT` | `8080` | Port des HTTP-Servers |
| `STREAMDOCK_BASE_URL` | `http://localhost:8080` | Öffentliche URL (für CORS und Share-Links) |
### Datenbank & Pfade
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_DB_PATH` | `./data/config/streamdock.db` | Pfad zur SQLite-Datenbank |
| `STREAMDOCK_RECORDINGS_PATH` | `./data/recordings` | Speicherort für Aufnahmen |
| `STREAMDOCK_AVATARS_PATH` | `./data/avatars` | Speicherort für Profilbilder |
### Sicherheit & Admin
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_JWT_SECRET` | `change-me-...` | **Unbedingt ändern!** Geheimer Schlüssel für JWT-Tokens |
| `STREAMDOCK_ADMIN_USER` | `admin` | Benutzername des initialen Admin-Accounts |
| `STREAMDOCK_ADMIN_PASSWORD` | `admin` | **Unbedingt ändern!** Passwort des Admin-Accounts |
| `STREAMDOCK_ADMIN_EMAIL` | `admin@example.com` | E-Mail des Admin-Accounts |
> **Wichtig:** `STREAMDOCK_JWT_SECRET` und `STREAMDOCK_ADMIN_PASSWORD` müssen vor dem ersten Start geändert werden. Der JWT-Secret sollte mindestens 32 zufällige Zeichen lang sein.
### SMTP (E-Mail-Benachrichtigungen)
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_SMTP_HOST` | | SMTP-Server Hostname |
| `STREAMDOCK_SMTP_PORT` | | SMTP-Server Port (z.B. 587 für STARTTLS) |
| `STREAMDOCK_SMTP_USER` | | SMTP-Benutzername |
| `STREAMDOCK_SMTP_PASSWORD` | | SMTP-Passwort |
| `STREAMDOCK_SMTP_FROM` | | Absender-Adresse |
| `STREAMDOCK_SMTP_FROM_NAME` | | Absender-Anzeigename |
### Last.fm
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_LASTFM_API_KEY` | | Last.fm API-Schlüssel |
| `STREAMDOCK_LASTFM_API_SECRET` | | Last.fm API-Secret |
> Last.fm API-Keys erhält man unter: https://www.last.fm/api/account/create
### Plik (Datei-Sharing)
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_PLIK_URL` | | URL der Plik-Instanz |
| `STREAMDOCK_PLIK_API_KEY` | | Plik API-Schlüssel |
### Ntfy (Push-Benachrichtigungen)
| Variable | Standard | Beschreibung |
|----------|---------|-------------|
| `STREAMDOCK_NTFY_DEFAULT_SERVER` | `https://ntfy.sh` | Standard Ntfy-Server URL |
---
## Beispiel .env-Datei
```env
# === Server ===
STREAMDOCK_HOST=0.0.0.0
STREAMDOCK_PORT=8080
STREAMDOCK_BASE_URL=http://localhost:8080
# === Sicherheit ===
STREAMDOCK_JWT_SECRET=mein-super-geheimer-schluessel-mit-mindestens-32-zeichen
STREAMDOCK_ADMIN_USER=admin
STREAMDOCK_ADMIN_PASSWORD=mein-sicheres-passwort
STREAMDOCK_ADMIN_EMAIL=admin@example.com
# === SMTP (optional) ===
# STREAMDOCK_SMTP_HOST=smtp.example.com
# STREAMDOCK_SMTP_PORT=587
# STREAMDOCK_SMTP_USER=user@example.com
# STREAMDOCK_SMTP_PASSWORD=smtp-passwort
# STREAMDOCK_SMTP_FROM=noreply@example.com
# STREAMDOCK_SMTP_FROM_NAME=StreamDock
# === Last.fm (optional) ===
# STREAMDOCK_LASTFM_API_KEY=dein-api-key
# STREAMDOCK_LASTFM_API_SECRET=dein-api-secret
# === Plik (optional) ===
# STREAMDOCK_PLIK_URL=https://plik.example.com
# STREAMDOCK_PLIK_API_KEY=dein-plik-key
# === Ntfy (optional) ===
# STREAMDOCK_NTFY_DEFAULT_SERVER=https://ntfy.sh
```
---
## Konfiguration zur Laufzeit
Einige Einstellungen können Benutzer über die Web-Oberfläche anpassen:
- **Last.fm:** Verbindung über OAuth im Bereich Einstellungen
- **Plik:** Server-URL und API-Key in den Benutzer-Einstellungen
- **Benachrichtigungen:** Kanäle (E-Mail, Webhook, Ntfy) und Events pro Benutzer
- **Lautstärke:** Wird automatisch pro Benutzer gespeichert
- **Profilbild:** Upload unter Profil-Einstellungen
---
Weiter: [Architektur](architektur.md)

135
docs/sicherheit.md Normal file
View File

@@ -0,0 +1,135 @@
# Sicherheit
## Authentifizierung
### JWT (JSON Web Token)
StreamDock verwendet JWT-basierte Authentifizierung:
- **Algorithmus:** HMAC-SHA256
- **Gültigkeit:** 24 Stunden
- **Token-Inhalt (Claims):**
- `user_id` Benutzer-ID
- `username` Benutzername
- `role` Rolle (admin/user)
- `iat` Ausstellungszeitpunkt
- `exp` Ablaufzeitpunkt
- `iss` Aussteller (StreamDock)
### Token-Übermittlung
Das JWT-Token kann auf drei Wegen übermittelt werden (in Prioritätsreihenfolge):
1. **Authorization Header:** `Authorization: Bearer <token>`
2. **Cookie:** `streamdock_token=<token>`
3. **Query-Parameter:** `?token=<token>` (Fallback für Streaming)
### Passwort-Hashing
- **Algorithmus:** bcrypt (Golang-Standard-Cost)
- Passwörter werden niemals im Klartext gespeichert
- Passwort-Änderung erfordert das aktuelle Passwort
---
## Zugriffskontrolle
### Middleware
Alle geschützten Endpunkte durchlaufen die Auth-Middleware:
1. **`AuthMiddleware`** Prüft JWT-Signatur und Ablauf, setzt Benutzer-Kontext
2. **`AdminOnly`** Zusätzliche Prüfung der Admin-Rolle
### Datenisolierung
- Alle Datenbank-Queries verwenden `WHERE user_id = ?`
- Benutzer können keine fremden Daten lesen, ändern oder löschen
- Share-Links ermöglichen kontrollierten öffentlichen Zugriff mit Token
---
## Container-Sicherheit
### Non-Root Prozess
Die Anwendung läuft als dedizierter Benutzer `streamdock` innerhalb des Containers:
```dockerfile
RUN adduser -D -h /app streamdock
```
Der Entrypoint (`su-exec`) sorgt dafür, dass der Go-Prozess ohne Root-Rechte ausgeführt wird.
### Kein Rootless Docker
> **Aktueller Stand:** Der Docker-Daemon selbst läuft mit Root-Rechten (Standard-Docker-Installation). Die Anwendung im Container läuft als Non-Root-User, der Docker-Daemon jedoch nicht.
>
> **Empfehlung für die Zukunft:** Migration zu [Rootless Docker](https://docs.docker.com/engine/security/rootless/) für erhöhte Host-Sicherheit. Dies betrifft die Docker-Installation auf dem Host-System, nicht den Container selbst.
### Statisches Binary
- `CGO_ENABLED=0` Keine C-Abhängigkeiten
- Minimales Alpine-Image Reduzierte Angriffsfläche
- Kein SSH, kein Shell-Zugang im Container nötig
---
## CORS (Cross-Origin Resource Sharing)
- CORS ist konfigurierbar über `STREAMDOCK_BASE_URL`
- Standardmäßig erlaubt StreamDock nur Anfragen von der konfigurierten Base-URL
- Credentials (Cookies) werden unterstützt
---
## SQL-Injection Schutz
- Alle Queries verwenden parametrisierte Statements (`?` Platzhalter)
- Kein String-Concatenation in SQL-Queries
- `sqlx`-Library mit automatischem Escaping
---
## Empfehlungen für den Betrieb
### Pflicht
| Maßnahme | Beschreibung |
|----------|-------------|
| JWT-Secret ändern | `STREAMDOCK_JWT_SECRET` mindestens 32 zufällige Zeichen |
| Admin-Passwort ändern | `STREAMDOCK_ADMIN_PASSWORD` durch sicheres Passwort ersetzen |
### Dringend empfohlen
| Maßnahme | Beschreibung |
|----------|-------------|
| HTTPS verwenden | Reverse Proxy mit TLS-Zertifikat (Let's Encrypt) |
| Base-URL setzen | `STREAMDOCK_BASE_URL` auf die tatsächliche Domain |
| Firewall einrichten | Port 8080 nur über Reverse Proxy erreichbar |
### Optional
| Maßnahme | Beschreibung |
|----------|-------------|
| Rootless Docker | Docker-Daemon ohne Root-Rechte betreiben |
| Regelmäßige Backups | `data/`-Verzeichnis regelmäßig sichern |
| Updates einspielen | Container regelmäßig neu bauen |
---
## Bekannte Einschränkungen
| Einschränkung | Beschreibung |
|--------------|-------------|
| **Alpha-Status** | Software befindet sich in aktiver Entwicklung; Sicherheitslücken sind möglich |
| **Kein Rate-Limiting** | Aktuell kein eingebautes Rate-Limiting für API-Endpunkte |
| **LocalStorage Token** | JWT wird clientseitig in localStorage gespeichert (XSS-Risiko) |
| **Kein 2FA** | Keine Zwei-Faktor-Authentifizierung implementiert |
| **Keine Audit-Logs** | Keine systematische Protokollierung sicherheitsrelevanter Aktionen |
> **Warnung:** StreamDock befindet sich im Alpha-Stadium und sollte **nicht ungeschützt im Internet betrieben** werden. Der Einsatz hinter einem VPN oder in einem vertrauenswürdigen Netzwerk wird empfohlen.
---
Weiter: [Entwicklung](entwicklung.md)

6
entrypoint.sh Normal file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
# Rechte auf Data-Verzeichnisse sicherstellen (Volumes können als root gemountet werden)
chown -R streamdock:streamdock /app/data
# Als streamdock-User die App starten
exec su-exec streamdock ./streamdock "$@"

26
go.mod Normal file
View File

@@ -0,0 +1,26 @@
module streamdock
go 1.23.0
require (
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/jmoiron/sqlx v1.4.0
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.37.0
modernc.org/sqlite v1.37.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/sys v0.32.0 // indirect
modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.9.1 // indirect
)

67
go.sum Normal file
View File

@@ -0,0 +1,67 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

1422
internal/api/handlers.go Normal file

File diff suppressed because it is too large Load Diff

134
internal/api/router.go Normal file
View File

@@ -0,0 +1,134 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"streamdock/internal/config"
"streamdock/internal/db"
"streamdock/internal/middleware"
"streamdock/internal/recorder"
"streamdock/internal/scheduler"
"streamdock/internal/videoproxy"
)
// NewRouter erstellt den HTTP-Router mit allen Routen.
func NewRouter(cfg *config.Config, database *db.DB, rec *recorder.Recorder, sched *scheduler.Scheduler, vp *videoproxy.Proxy) http.Handler {
r := chi.NewRouter()
// Globale Middleware
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
r.Use(chimiddleware.RealIP)
r.Use(chimiddleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{cfg.BaseURL},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
// Handler initialisieren
h := NewHandler(cfg, database, rec, sched)
h.VideoProxy = vp
// Statische Dateien (Frontend)
fileServer := http.FileServer(http.Dir("web/static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// PWA Dateien
r.Get("/manifest.json", serveFile("web/pwa/manifest.json"))
r.Get("/sw.js", serveFile("web/pwa/sw.js"))
// Öffentliche API-Routen
r.Group(func(r chi.Router) {
r.Post("/api/auth/login", h.Login)
r.Get("/api/share/{token}", h.GetSharedRecording)
r.Get("/api/share/{token}/download", h.DownloadSharedRecording)
r.Get("/api/share/{token}/stream", h.StreamSharedRecording)
r.Get("/api/radio-browser/search", h.SearchRadioBrowser)
})
// Geschützte API-Routen
r.Group(func(r chi.Router) {
r.Use(middleware.AuthMiddleware(cfg.JWTSecret))
// Profil
r.Get("/api/auth/me", h.GetProfile)
r.Put("/api/auth/me", h.UpdateProfile)
r.Put("/api/auth/me/password", h.ChangePassword)
r.Post("/api/auth/me/avatar", h.UploadAvatar)
// Benutzer-Einstellungen
r.Get("/api/settings", h.GetSettings)
r.Put("/api/settings", h.UpdateSettings)
r.Put("/api/settings/volume", h.UpdateVolume)
// Last.fm
r.Get("/api/lastfm/auth-url", h.GetLastFMAuthURL)
r.Post("/api/lastfm/callback", h.LastFMCallback)
r.Post("/api/lastfm/scrobble", h.ScrobbleToLastFM)
// Streams
r.Get("/api/streams", h.ListStreams)
r.Post("/api/streams", h.CreateStream)
r.Post("/api/streams/metadata", h.GetStreamMetadataByURL)
r.Get("/api/streams/{id}", h.GetStream)
r.Put("/api/streams/{id}", h.UpdateStream)
r.Delete("/api/streams/{id}", h.DeleteStream)
r.Post("/api/streams/{id}/check", h.CheckStream)
r.Get("/api/streams/{id}/now-playing", h.GetNowPlaying)
// Aufnahmen
r.Get("/api/recordings", h.ListRecordings)
r.Post("/api/recordings/start", h.StartRecording)
r.Post("/api/recordings/{id}/stop", h.StopRecording)
r.Get("/api/recordings/{id}", h.GetRecording)
r.Delete("/api/recordings/{id}", h.DeleteRecording)
r.Get("/api/recordings/{id}/download", h.DownloadRecording)
r.Get("/api/recordings/{id}/stream", h.StreamRecording)
r.Post("/api/recordings/{id}/share", h.ShareRecording)
r.Post("/api/recordings/{id}/plik", h.SendToPlik)
// Geplante Aufnahmen
r.Get("/api/schedules", h.ListSchedules)
r.Post("/api/schedules", h.CreateSchedule)
r.Put("/api/schedules/{id}", h.UpdateSchedule)
r.Delete("/api/schedules/{id}", h.DeleteSchedule)
// Bibliothek & Suche
r.Get("/api/library/search", h.SearchLibrary)
r.Get("/api/library/stats", h.LibraryStats)
// Video-Proxy (RTMP → HLS)
r.Post("/api/proxy/start", h.StartVideoProxy)
r.Post("/api/proxy/check", h.CheckProxyNeeded)
r.Delete("/api/proxy/{id}", h.StopVideoProxy)
r.Get("/api/proxy/hls/{id}/{file}", h.ServeHLS)
// Admin-Routen
r.Group(func(r chi.Router) {
r.Use(middleware.AdminOnly)
r.Get("/api/admin/users", h.AdminListUsers)
r.Post("/api/admin/users", h.AdminCreateUser)
r.Put("/api/admin/users/{id}", h.AdminUpdateUser)
r.Delete("/api/admin/users/{id}", h.AdminDeleteUser)
r.Put("/api/admin/users/{id}/quota", h.AdminSetQuota)
})
})
// SPA-Fallback: Alle anderen Routen liefern index.html
r.Get("/*", serveFile("web/templates/index.html"))
return r
}
func serveFile(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path)
}
}

91
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,91 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"streamdock/internal/models"
)
var (
ErrInvalidCredentials = errors.New("ungültige Anmeldedaten")
ErrTokenExpired = errors.New("Token abgelaufen")
ErrInvalidToken = errors.New("ungültiges Token")
)
// Claims sind die JWT-Claims für StreamDock.
type Claims struct {
UserID int64 `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// HashPassword hasht ein Passwort mit bcrypt.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// CheckPassword prüft ein Passwort gegen einen bcrypt-Hash.
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateToken erstellt ein JWT-Token für einen Benutzer.
func GenerateToken(user *models.User, secret string) (*models.TokenResponse, error) {
expiresAt := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "streamdock",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
return nil, err
}
return &models.TokenResponse{
Token: tokenString,
ExpiresAt: expiresAt.Unix(),
User: *user,
}, nil
}
// ValidateToken validiert ein JWT-Token und gibt die Claims zurück.
func ValidateToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return []byte(secret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}

106
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,106 @@
package auth
import (
"testing"
"time"
"streamdock/internal/models"
)
func TestHashPassword(t *testing.T) {
hash, err := HashPassword("testpassword")
if err != nil {
t.Fatalf("HashPassword fehlgeschlagen: %v", err)
}
if hash == "" {
t.Fatal("Hash darf nicht leer sein")
}
if hash == "testpassword" {
t.Fatal("Hash darf nicht dem Klartext-Passwort entsprechen")
}
}
func TestCheckPassword(t *testing.T) {
hash, _ := HashPassword("geheim123")
if !CheckPassword("geheim123", hash) {
t.Error("Korrektes Passwort wurde abgelehnt")
}
if CheckPassword("falsch", hash) {
t.Error("Falsches Passwort wurde akzeptiert")
}
if CheckPassword("", hash) {
t.Error("Leeres Passwort wurde akzeptiert")
}
}
func TestGenerateToken(t *testing.T) {
user := &models.User{
ID: 1,
Username: "testuser",
Role: "user",
}
secret := "test-secret-key"
resp, err := GenerateToken(user, secret)
if err != nil {
t.Fatalf("GenerateToken fehlgeschlagen: %v", err)
}
if resp.Token == "" {
t.Error("Token darf nicht leer sein")
}
if resp.ExpiresAt <= time.Now().Unix() {
t.Error("Token sollte in der Zukunft ablaufen")
}
if resp.User.Username != "testuser" {
t.Error("User im TokenResponse stimmt nicht")
}
}
func TestValidateToken(t *testing.T) {
secret := "test-secret-key"
user := &models.User{
ID: 42,
Username: "admin",
Role: "admin",
}
resp, _ := GenerateToken(user, secret)
claims, err := ValidateToken(resp.Token, secret)
if err != nil {
t.Fatalf("ValidateToken fehlgeschlagen: %v", err)
}
if claims.UserID != 42 {
t.Errorf("Erwartete UserID 42, bekam %d", claims.UserID)
}
if claims.Username != "admin" {
t.Errorf("Erwarteter Username admin, bekam %s", claims.Username)
}
if claims.Role != "admin" {
t.Errorf("Erwartete Role admin, bekam %s", claims.Role)
}
}
func TestValidateToken_WrongSecret(t *testing.T) {
user := &models.User{ID: 1, Username: "test", Role: "user"}
resp, _ := GenerateToken(user, "secret-a")
_, err := ValidateToken(resp.Token, "secret-b")
if err == nil {
t.Error("Token mit falschem Secret sollte fehlschlagen")
}
}
func TestValidateToken_Invalid(t *testing.T) {
_, err := ValidateToken("invalid-token", "secret")
if err == nil {
t.Error("Ungültiges Token sollte Fehler zurückgeben")
}
}

91
internal/config/config.go Normal file
View File

@@ -0,0 +1,91 @@
package config
import (
"os"
"path/filepath"
)
// Config enthält die gesamte Anwendungskonfiguration.
type Config struct {
Host string
Port string
BaseURL string
JWTSecret string
DBPath string
RecordingsPath string
AvatarsPath string
AdminUser string
AdminPassword string
AdminEmail string
// SMTP
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPFromName string
// Last.fm
LastFMApiKey string
LastFMApiSecret string
// Plik
PlikURL string
PlikAPIKey string
// Ntfy
NtfyDefaultServer string
}
// Load liest die Konfiguration aus Umgebungsvariablen.
func Load() (*Config, error) {
cfg := &Config{
Host: getEnv("STREAMDOCK_HOST", "0.0.0.0"),
Port: getEnv("STREAMDOCK_PORT", "8080"),
BaseURL: getEnv("STREAMDOCK_BASE_URL", "http://localhost:8080"),
JWTSecret: getEnv("STREAMDOCK_JWT_SECRET", "change-me-to-a-random-secret-string"),
DBPath: getEnv("STREAMDOCK_DB_PATH", "./data/config/streamdock.db"),
RecordingsPath: getEnv("STREAMDOCK_RECORDINGS_PATH", "./data/recordings"),
AvatarsPath: getEnv("STREAMDOCK_AVATARS_PATH", "./data/avatars"),
AdminUser: getEnv("STREAMDOCK_ADMIN_USER", "admin"),
AdminPassword: getEnv("STREAMDOCK_ADMIN_PASSWORD", "admin"),
AdminEmail: getEnv("STREAMDOCK_ADMIN_EMAIL", "admin@example.com"),
SMTPHost: getEnv("STREAMDOCK_SMTP_HOST", ""),
SMTPPort: getEnv("STREAMDOCK_SMTP_PORT", "587"),
SMTPUser: getEnv("STREAMDOCK_SMTP_USER", ""),
SMTPPassword: getEnv("STREAMDOCK_SMTP_PASSWORD", ""),
SMTPFrom: getEnv("STREAMDOCK_SMTP_FROM", "streamdock@example.com"),
SMTPFromName: getEnv("STREAMDOCK_SMTP_FROM_NAME", "StreamDock"),
LastFMApiKey: getEnv("STREAMDOCK_LASTFM_API_KEY", ""),
LastFMApiSecret: getEnv("STREAMDOCK_LASTFM_API_SECRET", ""),
PlikURL: getEnv("STREAMDOCK_PLIK_URL", ""),
PlikAPIKey: getEnv("STREAMDOCK_PLIK_API_KEY", ""),
NtfyDefaultServer: getEnv("STREAMDOCK_NTFY_DEFAULT_SERVER", "https://ntfy.sh"),
}
// DB-Verzeichnis sicherstellen
dbDir := filepath.Dir(cfg.DBPath)
if err := os.MkdirAll(dbDir, 0750); err != nil {
return nil, err
}
return cfg, nil
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return fallback
}

190
internal/db/db.go Normal file
View File

@@ -0,0 +1,190 @@
package db
import (
"crypto/rand"
"encoding/hex"
"time"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
// DB kapselt die Datenbankverbindung.
type DB struct {
*sqlx.DB
}
// New erstellt eine neue Datenbankverbindung.
func New(dbPath string) (*DB, error) {
db, err := sqlx.Connect("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // SQLite unterstützt nur einen Writer
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &DB{db}, nil
}
// Migrate führt Datenbankmigrationen aus.
func (d *DB) Migrate() error {
if _, err := d.Exec(schema); err != nil {
return err
}
// Spalte volume zu user_settings hinzufügen (falls noch nicht vorhanden)
d.Exec("ALTER TABLE user_settings ADD COLUMN volume INTEGER DEFAULT 80")
return nil
}
// EnsureAdmin stellt sicher, dass ein Admin-Account existiert.
func (d *DB) EnsureAdmin(username, password, email string) error {
var count int
err := d.Get(&count, "SELECT COUNT(*) FROM users WHERE role = 'admin'")
if err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
now := time.Now()
_, err = d.Exec(
`INSERT INTO users (username, email, password_hash, role, storage_quota, created_at, updated_at)
VALUES (?, ?, ?, 'admin', 0, ?, ?)`,
username, email, string(hash), now, now,
)
if err != nil {
return err
}
// UserSettings für Admin anlegen
var userID int64
err = d.Get(&userID, "SELECT id FROM users WHERE username = ?", username)
if err != nil {
return err
}
_, err = d.Exec("INSERT INTO user_settings (user_id) VALUES (?)", userID)
return err
}
// GenerateToken erstellt ein kryptographisch sicheres Token.
func GenerateToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
// Schema definiert die Datenbanktabellen.
var schema = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
avatar_path TEXT DEFAULT '',
storage_quota INTEGER DEFAULT 0,
storage_used INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
lastfm_session_key TEXT DEFAULT '',
lastfm_username TEXT DEFAULT '',
plik_url TEXT DEFAULT '',
plik_api_key TEXT DEFAULT '',
notify_email BOOLEAN DEFAULT 0,
notify_email_addr TEXT DEFAULT '',
notify_webhook BOOLEAN DEFAULT 0,
notify_webhook_url TEXT DEFAULT '',
notify_ntfy BOOLEAN DEFAULT 0,
notify_ntfy_topic TEXT DEFAULT '',
notify_ntfy_server TEXT DEFAULT '',
notify_on_login BOOLEAN DEFAULT 0,
notify_on_rec_start BOOLEAN DEFAULT 1,
notify_on_rec_end BOOLEAN DEFAULT 1,
notify_on_rec_error BOOLEAN DEFAULT 1,
volume INTEGER DEFAULT 80
);
CREATE TABLE IF NOT EXISTS streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
url TEXT NOT NULL,
stream_type TEXT NOT NULL DEFAULT 'audio' CHECK(stream_type IN ('audio', 'video')),
content_type TEXT DEFAULT '',
bitrate INTEGER DEFAULT 0,
sample_rate INTEGER DEFAULT 0,
genre TEXT DEFAULT '',
description TEXT DEFAULT '',
logo_url TEXT DEFAULT '',
is_valid BOOLEAN DEFAULT 0,
last_checked DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS scheduled_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stream_id INTEGER NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
cron_expr TEXT DEFAULT '',
start_time DATETIME NOT NULL,
duration INTEGER NOT NULL DEFAULT 3600,
is_recurring BOOLEAN DEFAULT 0,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stream_id INTEGER NOT NULL REFERENCES streams(id) ON DELETE SET NULL,
stream_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER DEFAULT 0,
duration INTEGER DEFAULT 0,
format TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'recording' CHECK(status IN ('recording', 'completed', 'error')),
share_token TEXT DEFAULT '',
started_at DATETIME NOT NULL,
stopped_at DATETIME,
created_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recording_id INTEGER NOT NULL REFERENCES recordings(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME,
max_downloads INTEGER DEFAULT 0,
downloads INTEGER DEFAULT 0,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_streams_user ON streams(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_user ON recordings(user_id);
CREATE INDEX IF NOT EXISTS idx_recordings_status ON recordings(status);
CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token);
CREATE INDEX IF NOT EXISTS idx_scheduled_recordings_active ON scheduled_recordings(is_active);
`

345
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,345 @@
package db
import (
"os"
"testing"
"time"
"streamdock/internal/models"
)
// newTestDB erstellt eine temporäre SQLite-Datenbank für Tests.
func newTestDB(t *testing.T) *DB {
t.Helper()
tmpFile, err := os.CreateTemp("", "streamdock_test_*.db")
if err != nil {
t.Fatalf("Temp-DB erstellen fehlgeschlagen: %v", err)
}
tmpFile.Close()
t.Cleanup(func() { os.Remove(tmpFile.Name()) })
db, err := New(tmpFile.Name())
if err != nil {
t.Fatalf("DB öffnen fehlgeschlagen: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(); err != nil {
t.Fatalf("Migration fehlgeschlagen: %v", err)
}
return db
}
func TestCreateAndGetUser(t *testing.T) {
db := newTestDB(t)
user, err := db.CreateUser("alice", "alice@example.com", "pass123", "user", 0)
if err != nil {
t.Fatalf("CreateUser fehlgeschlagen: %v", err)
}
if user.ID == 0 {
t.Error("UserID darf nicht 0 sein")
}
if user.Username != "alice" {
t.Errorf("Erwarteter Username alice, bekam %s", user.Username)
}
fetched, err := db.GetUserByID(user.ID)
if err != nil {
t.Fatalf("GetUserByID fehlgeschlagen: %v", err)
}
if fetched.Email != "alice@example.com" {
t.Errorf("Erwartete Email alice@example.com, bekam %s", fetched.Email)
}
fetched2, err := db.GetUserByUsername("alice")
if err != nil {
t.Fatalf("GetUserByUsername fehlgeschlagen: %v", err)
}
if fetched2.ID != user.ID {
t.Error("GetUserByUsername gab falschen User zurück")
}
}
func TestListUsers(t *testing.T) {
db := newTestDB(t)
db.CreateUser("user1", "u1@test.de", "pw", "user", 0)
db.CreateUser("user2", "u2@test.de", "pw", "admin", 0)
users, err := db.ListUsers()
if err != nil {
t.Fatalf("ListUsers fehlgeschlagen: %v", err)
}
if len(users) != 2 {
t.Errorf("Erwartete 2 Benutzer, bekam %d", len(users))
}
}
func TestDeleteUser(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("todelete", "del@test.de", "pw", "user", 0)
err := db.DeleteUser(user.ID)
if err != nil {
t.Fatalf("DeleteUser fehlgeschlagen: %v", err)
}
_, err = db.GetUserByID(user.ID)
if err == nil {
t.Error("Gelöschter Benutzer sollte nicht mehr gefunden werden")
}
}
func TestStreamCRUD(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("streamer", "s@test.de", "pw", "user", 0)
// Create
stream, err := db.CreateStream(&models.Stream{
UserID: user.ID,
Name: "TestRadio",
URL: "http://stream.test/live",
StreamType: "audio",
ContentType: "audio/mpeg",
Bitrate: 128,
SampleRate: 44100,
Genre: "Rock",
Description: "Test-Stream",
})
if err != nil {
t.Fatalf("CreateStream fehlgeschlagen: %v", err)
}
if stream.Name != "TestRadio" {
t.Errorf("Erwarteter Name TestRadio, bekam %s", stream.Name)
}
// List
streams, err := db.ListStreamsByUser(user.ID)
if err != nil {
t.Fatalf("ListStreamsByUser fehlgeschlagen: %v", err)
}
if len(streams) != 1 {
t.Errorf("Erwartete 1 Stream, bekam %d", len(streams))
}
// Get
fetched, err := db.GetStream(stream.ID)
if err != nil {
t.Fatalf("GetStream fehlgeschlagen: %v", err)
}
if fetched.URL != "http://stream.test/live" {
t.Error("Stream-URL stimmt nicht")
}
// Update
fetched.Name = "NewRadio"
fetched.URL = "http://new.test/live"
fetched.Bitrate = 256
err = db.UpdateStream(fetched)
if err != nil {
t.Fatalf("UpdateStream fehlgeschlagen: %v", err)
}
updated, _ := db.GetStream(stream.ID)
if updated.Name != "NewRadio" {
t.Errorf("Stream-Name nicht aktualisiert: %s", updated.Name)
}
// Delete
err = db.DeleteStream(stream.ID)
if err != nil {
t.Fatalf("DeleteStream fehlgeschlagen: %v", err)
}
_, err = db.GetStream(stream.ID)
if err == nil {
t.Error("Gelöschter Stream sollte nicht mehr gefunden werden")
}
}
func TestRecordingCRUD(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("recorder", "rec@test.de", "pw", "user", 0)
stream, _ := db.CreateStream(&models.Stream{
UserID: user.ID, Name: "Radio", URL: "http://r.test/live",
StreamType: "audio", ContentType: "audio/mpeg",
})
rec, err := db.CreateRecording(&models.Recording{
UserID: user.ID, StreamID: stream.ID, StreamName: "Radio",
FilePath: "/tmp/rec.mp3", FileName: "rec.mp3", Format: "mp3",
})
if err != nil {
t.Fatalf("CreateRecording fehlgeschlagen: %v", err)
}
if rec.Status != "recording" {
t.Errorf("Erwarteter Status recording, bekam %s", rec.Status)
}
err = db.UpdateRecordingStatus(rec.ID, "completed", 1024, 60)
if err != nil {
t.Fatalf("UpdateRecordingStatus fehlgeschlagen: %v", err)
}
recordings, err := db.ListRecordingsByUser(user.ID)
if err != nil {
t.Fatalf("ListRecordingsByUser fehlgeschlagen: %v", err)
}
if len(recordings) != 1 {
t.Errorf("Erwartete 1 Aufnahme, bekam %d", len(recordings))
}
if recordings[0].Status != "completed" {
t.Errorf("Status nicht aktualisiert: %s", recordings[0].Status)
}
}
func TestScheduleCRUD(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("scheduler", "sch@test.de", "pw", "user", 0)
stream, _ := db.CreateStream(&models.Stream{
UserID: user.ID, Name: "Radio", URL: "http://r.test/live",
StreamType: "audio", ContentType: "audio/mpeg",
})
sched, err := db.CreateSchedule(&models.ScheduledRecording{
UserID: user.ID,
StreamID: stream.ID,
CronExpr: "0 8 * * *",
StartTime: time.Now(),
Duration: 3600,
IsRecurring: true,
IsActive: true,
})
if err != nil {
t.Fatalf("CreateSchedule fehlgeschlagen: %v", err)
}
if !sched.IsActive {
t.Error("Schedule sollte aktiv sein")
}
schedules, err := db.ListSchedulesByUser(user.ID)
if err != nil {
t.Fatalf("ListSchedulesByUser fehlgeschlagen: %v", err)
}
if len(schedules) != 1 {
t.Errorf("Erwartete 1 Schedule, bekam %d", len(schedules))
}
active, err := db.ListActiveSchedules()
if err != nil {
t.Fatalf("ListActiveSchedules fehlgeschlagen: %v", err)
}
if len(active) != 1 {
t.Errorf("Erwartete 1 aktiven Schedule, bekam %d", len(active))
}
// Toggle off
sched.IsActive = false
err = db.UpdateSchedule(sched)
if err != nil {
t.Fatalf("UpdateSchedule fehlgeschlagen: %v", err)
}
active, _ = db.ListActiveSchedules()
if len(active) != 0 {
t.Error("Deaktivierter Schedule sollte nicht in aktiven sein")
}
err = db.DeleteSchedule(sched.ID)
if err != nil {
t.Fatalf("DeleteSchedule fehlgeschlagen: %v", err)
}
}
func TestShareLink(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("sharer", "sh@test.de", "pw", "user", 0)
stream, _ := db.CreateStream(&models.Stream{
UserID: user.ID, Name: "Radio", URL: "http://r.test/live",
StreamType: "audio", ContentType: "audio/mpeg",
})
rec, _ := db.CreateRecording(&models.Recording{
UserID: user.ID, StreamID: stream.ID, StreamName: "Radio",
FilePath: "/tmp/rec.mp3", FileName: "rec.mp3", Format: "mp3",
})
expires := time.Now().Add(24 * time.Hour)
link, err := db.CreateShareLink(rec.ID, user.ID, "test-token-123", &expires, 5)
if err != nil {
t.Fatalf("CreateShareLink fehlgeschlagen: %v", err)
}
if link.Token != "test-token-123" {
t.Error("Token stimmt nicht")
}
fetched, err := db.GetShareLinkByToken("test-token-123")
if err != nil {
t.Fatalf("GetShareLinkByToken fehlgeschlagen: %v", err)
}
if fetched.MaxDownloads != 5 {
t.Errorf("Erwartete MaxDownloads 5, bekam %d", fetched.MaxDownloads)
}
err = db.IncrementShareDownloads(link.ID)
if err != nil {
t.Fatalf("IncrementShareDownloads fehlgeschlagen: %v", err)
}
updated, _ := db.GetShareLinkByToken("test-token-123")
if updated.Downloads != 1 {
t.Errorf("Erwartete Downloads 1, bekam %d", updated.Downloads)
}
}
func TestUserSettings(t *testing.T) {
db := newTestDB(t)
user, _ := db.CreateUser("settings", "set@test.de", "pw", "user", 0)
settings, err := db.GetUserSettings(user.ID)
if err != nil {
t.Fatalf("GetUserSettings fehlgeschlagen: %v", err)
}
if settings.UserID != user.ID {
t.Error("UserID in Settings stimmt nicht")
}
settings.NotifyEmail = true
settings.NotifyEmailAddr = "test@mail.de"
err = db.UpdateUserSettings(settings)
if err != nil {
t.Fatalf("UpdateUserSettings fehlgeschlagen: %v", err)
}
updated, _ := db.GetUserSettings(user.ID)
if !updated.NotifyEmail {
t.Error("NotifyEmail sollte true sein")
}
if updated.NotifyEmailAddr != "test@mail.de" {
t.Error("NotifyEmailAddr stimmt nicht")
}
}
func TestEnsureAdmin(t *testing.T) {
db := newTestDB(t)
err := db.EnsureAdmin("admin", "adminpw", "admin@test.de")
if err != nil {
t.Fatalf("EnsureAdmin fehlgeschlagen: %v", err)
}
admin, err := db.GetUserByUsername("admin")
if err != nil {
t.Fatalf("Admin nicht gefunden: %v", err)
}
if admin.Role != "admin" {
t.Error("Admin sollte die Rolle admin haben")
}
// Erneuter Aufruf sollte keinen zweiten Admin erstellen
err = db.EnsureAdmin("admin2", "pw", "admin2@test.de")
if err != nil {
t.Fatalf("Zweiter EnsureAdmin fehlgeschlagen: %v", err)
}
users, _ := db.ListUsers()
if len(users) != 1 {
t.Errorf("Erwartete 1 Benutzer, bekam %d", len(users))
}
}

449
internal/db/queries.go Normal file
View File

@@ -0,0 +1,449 @@
package db
import (
"fmt"
"time"
"golang.org/x/crypto/bcrypt"
"streamdock/internal/models"
)
// ==================== Users ====================
// GetUserByUsername sucht einen Benutzer anhand des Benutzernamens.
func (d *DB) GetUserByUsername(username string) (*models.User, error) {
var user models.User
err := d.Get(&user, "SELECT * FROM users WHERE username = ?", username)
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID sucht einen Benutzer anhand der ID.
func (d *DB) GetUserByID(id int64) (*models.User, error) {
var user models.User
err := d.Get(&user, "SELECT * FROM users WHERE id = ?", id)
if err != nil {
return nil, err
}
return &user, nil
}
// ListUsers gibt alle Benutzer zurück.
func (d *DB) ListUsers() ([]models.User, error) {
var users []models.User
err := d.Select(&users, "SELECT * FROM users ORDER BY created_at DESC")
return users, err
}
// CreateUser erstellt einen neuen Benutzer.
func (d *DB) CreateUser(username, email, password, role string, quota int64) (*models.User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
now := time.Now()
result, err := d.Exec(
`INSERT INTO users (username, email, password_hash, role, storage_quota, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
username, email, string(hash), role, quota, now, now,
)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
// UserSettings anlegen
_, err = d.Exec("INSERT INTO user_settings (user_id) VALUES (?)", id)
if err != nil {
return nil, err
}
return d.GetUserByID(id)
}
// UpdateUser aktualisiert einen Benutzer.
func (d *DB) UpdateUser(id int64, username, email string) error {
_, err := d.Exec(
"UPDATE users SET username = ?, email = ?, updated_at = ? WHERE id = ?",
username, email, time.Now(), id,
)
return err
}
// UpdateUserPassword ändert das Passwort eines Benutzers.
func (d *DB) UpdateUserPassword(id int64, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
_, err = d.Exec("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", string(hash), time.Now(), id)
return err
}
// UpdateUserAvatar aktualisiert den Avatar-Pfad.
func (d *DB) UpdateUserAvatar(id int64, path string) error {
_, err := d.Exec("UPDATE users SET avatar_path = ?, updated_at = ? WHERE id = ?", path, time.Now(), id)
return err
}
// UpdateUserRole aktualisiert die Rolle eines Benutzers.
func (d *DB) UpdateUserRole(id int64, role string) error {
_, err := d.Exec("UPDATE users SET role = ?, updated_at = ? WHERE id = ?", role, time.Now(), id)
return err
}
// UpdateUserQuota setzt das Speicher-Kontingent.
func (d *DB) UpdateUserQuota(id int64, quota int64) error {
_, err := d.Exec("UPDATE users SET storage_quota = ?, updated_at = ? WHERE id = ?", quota, time.Now(), id)
return err
}
// UpdateUserStorageUsed aktualisiert den belegten Speicher.
func (d *DB) UpdateUserStorageUsed(id int64, used int64) error {
_, err := d.Exec("UPDATE users SET storage_used = ?, updated_at = ? WHERE id = ?", used, time.Now(), id)
return err
}
// DeleteUser löscht einen Benutzer.
func (d *DB) DeleteUser(id int64) error {
_, err := d.Exec("DELETE FROM users WHERE id = ?", id)
return err
}
// ==================== UserSettings ====================
// GetUserSettings gibt die Einstellungen eines Benutzers zurück.
func (d *DB) GetUserSettings(userID int64) (*models.UserSettings, error) {
var settings models.UserSettings
err := d.Get(&settings, "SELECT * FROM user_settings WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateUserSettings aktualisiert die Einstellungen.
func (d *DB) UpdateUserSettings(s *models.UserSettings) error {
_, err := d.Exec(`
UPDATE user_settings SET
lastfm_session_key = ?, lastfm_username = ?,
plik_url = ?, plik_api_key = ?,
notify_email = ?, notify_email_addr = ?,
notify_webhook = ?, notify_webhook_url = ?,
notify_ntfy = ?, notify_ntfy_topic = ?, notify_ntfy_server = ?,
notify_on_login = ?, notify_on_rec_start = ?, notify_on_rec_end = ?, notify_on_rec_error = ?,
volume = ?
WHERE user_id = ?`,
s.LastFMSessionKey, s.LastFMUsername,
s.PlikURL, s.PlikAPIKey,
s.NotifyEmail, s.NotifyEmailAddr,
s.NotifyWebhook, s.NotifyWebhookURL,
s.NotifyNtfy, s.NotifyNtfyTopic, s.NotifyNtfyServer,
s.NotifyOnLogin, s.NotifyOnRecStart, s.NotifyOnRecEnd, s.NotifyOnRecError,
s.Volume,
s.UserID,
)
return err
}
// ==================== Streams ====================
// ListStreamsByUser gibt alle Streams eines Benutzers zurück.
func (d *DB) ListStreamsByUser(userID int64) ([]models.Stream, error) {
var streams []models.Stream
err := d.Select(&streams, "SELECT * FROM streams WHERE user_id = ? ORDER BY created_at DESC", userID)
return streams, err
}
// GetStream gibt einen Stream anhand der ID zurück.
func (d *DB) GetStream(id int64) (*models.Stream, error) {
var stream models.Stream
err := d.Get(&stream, "SELECT * FROM streams WHERE id = ?", id)
if err != nil {
return nil, err
}
return &stream, nil
}
// CreateStream erstellt einen neuen Stream.
func (d *DB) CreateStream(s *models.Stream) (*models.Stream, error) {
now := time.Now()
result, err := d.Exec(`
INSERT INTO streams (user_id, name, url, stream_type, content_type, bitrate, sample_rate, genre, description, logo_url, is_valid, last_checked, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
s.UserID, s.Name, s.URL, s.StreamType, s.ContentType, s.Bitrate, s.SampleRate, s.Genre, s.Description, s.LogoURL, s.IsValid, now, now, now,
)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return d.GetStream(id)
}
// UpdateStream aktualisiert einen Stream.
func (d *DB) UpdateStream(s *models.Stream) error {
_, err := d.Exec(`
UPDATE streams SET name = ?, url = ?, stream_type = ?, content_type = ?, bitrate = ?, sample_rate = ?,
genre = ?, description = ?, logo_url = ?, is_valid = ?, last_checked = ?, updated_at = ?
WHERE id = ?`,
s.Name, s.URL, s.StreamType, s.ContentType, s.Bitrate, s.SampleRate,
s.Genre, s.Description, s.LogoURL, s.IsValid, s.LastChecked, time.Now(),
s.ID,
)
return err
}
// DeleteStream löscht einen Stream.
func (d *DB) DeleteStream(id int64) error {
_, err := d.Exec("DELETE FROM streams WHERE id = ?", id)
return err
}
// ==================== Recordings ====================
// ListRecordingsByUser gibt alle Aufnahmen eines Benutzers zurück.
func (d *DB) ListRecordingsByUser(userID int64) ([]models.Recording, error) {
var recordings []models.Recording
err := d.Select(&recordings, "SELECT * FROM recordings WHERE user_id = ? ORDER BY created_at DESC", userID)
return recordings, err
}
// GetRecording gibt eine Aufnahme anhand der ID zurück.
func (d *DB) GetRecording(id int64) (*models.Recording, error) {
var recording models.Recording
err := d.Get(&recording, "SELECT * FROM recordings WHERE id = ?", id)
if err != nil {
return nil, err
}
return &recording, nil
}
// CreateRecording erstellt eine neue Aufnahme.
func (d *DB) CreateRecording(r *models.Recording) (*models.Recording, error) {
now := time.Now()
result, err := d.Exec(`
INSERT INTO recordings (user_id, stream_id, stream_name, file_path, file_name, file_size, duration, format, status, started_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.UserID, r.StreamID, r.StreamName, r.FilePath, r.FileName, 0, 0, r.Format, "recording", now, now,
)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return d.GetRecording(id)
}
// UpdateRecordingStatus aktualisiert den Status einer Aufnahme.
func (d *DB) UpdateRecordingStatus(id int64, status string, fileSize int64, duration int) error {
_, err := d.Exec(
"UPDATE recordings SET status = ?, file_size = ?, duration = ?, stopped_at = ? WHERE id = ?",
status, fileSize, duration, time.Now(), id,
)
return err
}
// DeleteRecording löscht eine Aufnahme.
func (d *DB) DeleteRecording(id int64) error {
_, err := d.Exec("DELETE FROM recordings WHERE id = ?", id)
return err
}
// ==================== Share Links ====================
// CreateShareLink erstellt einen Teilen-Link.
func (d *DB) CreateShareLink(recordingID, userID int64, token string, expiresAt *time.Time, maxDownloads int) (*models.ShareLink, error) {
now := time.Now()
result, err := d.Exec(`
INSERT INTO share_links (recording_id, user_id, token, expires_at, max_downloads, downloads, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?)`,
recordingID, userID, token, expiresAt, maxDownloads, now,
)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
var link models.ShareLink
err = d.Get(&link, "SELECT * FROM share_links WHERE id = ?", id)
if err != nil {
return nil, err
}
return &link, nil
}
// GetShareLinkByToken sucht einen ShareLink anhand des Tokens.
func (d *DB) GetShareLinkByToken(token string) (*models.ShareLink, error) {
var link models.ShareLink
err := d.Get(&link, "SELECT * FROM share_links WHERE token = ?", token)
if err != nil {
return nil, err
}
return &link, nil
}
// IncrementShareDownloads erhöht den Download-Zähler.
func (d *DB) IncrementShareDownloads(id int64) error {
_, err := d.Exec("UPDATE share_links SET downloads = downloads + 1 WHERE id = ?", id)
return err
}
// ==================== Scheduled Recordings ====================
// ListSchedulesByUser gibt alle geplanten Aufnahmen eines Benutzers zurück.
func (d *DB) ListSchedulesByUser(userID int64) ([]models.ScheduledRecording, error) {
var schedules []models.ScheduledRecording
err := d.Select(&schedules, "SELECT * FROM scheduled_recordings WHERE user_id = ? ORDER BY start_time ASC", userID)
return schedules, err
}
// GetSchedule gibt eine geplante Aufnahme zurück.
func (d *DB) GetSchedule(id int64) (*models.ScheduledRecording, error) {
var schedule models.ScheduledRecording
err := d.Get(&schedule, "SELECT * FROM scheduled_recordings WHERE id = ?", id)
if err != nil {
return nil, err
}
return &schedule, nil
}
// ListActiveSchedules gibt alle aktiven geplanten Aufnahmen zurück.
func (d *DB) ListActiveSchedules() ([]models.ScheduledRecording, error) {
var schedules []models.ScheduledRecording
err := d.Select(&schedules, "SELECT * FROM scheduled_recordings WHERE is_active = 1")
return schedules, err
}
// CreateSchedule erstellt eine geplante Aufnahme.
func (d *DB) CreateSchedule(s *models.ScheduledRecording) (*models.ScheduledRecording, error) {
now := time.Now()
result, err := d.Exec(`
INSERT INTO scheduled_recordings (user_id, stream_id, cron_expr, start_time, duration, is_recurring, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
s.UserID, s.StreamID, s.CronExpr, s.StartTime, s.Duration, s.IsRecurring, s.IsActive, now,
)
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
return d.GetSchedule(id)
}
// UpdateSchedule aktualisiert eine geplante Aufnahme.
func (d *DB) UpdateSchedule(s *models.ScheduledRecording) error {
_, err := d.Exec(`
UPDATE scheduled_recordings SET stream_id = ?, cron_expr = ?, start_time = ?, duration = ?, is_recurring = ?, is_active = ?
WHERE id = ?`,
s.StreamID, s.CronExpr, s.StartTime, s.Duration, s.IsRecurring, s.IsActive,
s.ID,
)
return err
}
// DeleteSchedule löscht eine geplante Aufnahme.
func (d *DB) DeleteSchedule(id int64) error {
_, err := d.Exec("DELETE FROM scheduled_recordings WHERE id = ?", id)
return err
}
// ==================== Library / Search ====================
// SearchStreams sucht in den Streams eines Benutzers.
func (d *DB) SearchStreams(userID int64, query string) ([]models.Stream, error) {
var streams []models.Stream
q := "%" + query + "%"
err := d.Select(&streams,
"SELECT * FROM streams WHERE user_id = ? AND (name LIKE ? OR genre LIKE ? OR description LIKE ?) ORDER BY name",
userID, q, q, q,
)
return streams, err
}
// SearchRecordings sucht in den Aufnahmen eines Benutzers.
func (d *DB) SearchRecordings(userID int64, query string) ([]models.Recording, error) {
var recordings []models.Recording
q := "%" + query + "%"
err := d.Select(&recordings,
"SELECT * FROM recordings WHERE user_id = ? AND (stream_name LIKE ? OR file_name LIKE ?) ORDER BY created_at DESC",
userID, q, q,
)
return recordings, err
}
// LibraryStats gibt Statistiken der Bibliothek eines Benutzers zurück.
func (d *DB) LibraryStats(userID int64) (map[string]interface{}, error) {
stats := map[string]interface{}{}
var streamCount int
if err := d.Get(&streamCount, "SELECT COUNT(*) FROM streams WHERE user_id = ?", userID); err != nil {
return nil, err
}
stats["streams"] = streamCount
var recCount int
if err := d.Get(&recCount, "SELECT COUNT(*) FROM recordings WHERE user_id = ?", userID); err != nil {
return nil, err
}
stats["recordings"] = recCount
var activeRecCount int
if err := d.Get(&activeRecCount, "SELECT COUNT(*) FROM recordings WHERE user_id = ? AND status = 'recording'", userID); err != nil {
return nil, err
}
stats["activeRecordings"] = activeRecCount
var storageUsed int64
err := d.Get(&storageUsed, "SELECT COALESCE(SUM(file_size), 0) FROM recordings WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
stats["storageUsed"] = storageUsed
// User-Quota
user, err := d.GetUserByID(userID)
if err != nil {
return nil, err
}
stats["storageQuota"] = user.StorageQuota
var schedCount int
if err := d.Get(&schedCount, "SELECT COUNT(*) FROM scheduled_recordings WHERE user_id = ? AND is_active = 1", userID); err != nil {
return nil, err
}
stats["activeSchedules"] = schedCount
return stats, nil
}
// CalcUserStorageUsed berechnet tatsächlich belegten Speicher.
func (d *DB) CalcUserStorageUsed(userID int64) (int64, error) {
var used int64
err := d.Get(&used, "SELECT COALESCE(SUM(file_size), 0) FROM recordings WHERE user_id = ?", userID)
return used, err
}
// CheckUserQuota prüft ob ein Benutzer noch Speicherplatz hat.
func (d *DB) CheckUserQuota(userID int64) error {
user, err := d.GetUserByID(userID)
if err != nil {
return err
}
if user.StorageQuota == 0 {
return nil // Unbegrenzt
}
used, err := d.CalcUserStorageUsed(userID)
if err != nil {
return err
}
if used >= user.StorageQuota {
return fmt.Errorf("Speicherkontingent erschöpft (%d / %d Bytes)", used, user.StorageQuota)
}
return nil
}

160
internal/lastfm/lastfm.go Normal file
View File

@@ -0,0 +1,160 @@
package lastfm
import (
"crypto/md5"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const (
apiBaseURL = "https://ws.audioscrobbler.com/2.0/"
authBaseURL = "https://www.last.fm/api/auth/"
)
// Client ist der Last.fm API-Client.
type Client struct {
APIKey string
APISecret string
HTTPClient *http.Client
}
// NewClient erstellt einen neuen Last.fm Client.
func NewClient(apiKey, apiSecret string) *Client {
return &Client{
APIKey: apiKey,
APISecret: apiSecret,
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
// GetAuthURL gibt die URL zurück, zu der der Benutzer für die Autorisierung geleitet wird.
func (c *Client) GetAuthURL(callbackURL string) string {
params := url.Values{
"api_key": {c.APIKey},
"cb": {callbackURL},
}
return authBaseURL + "?" + params.Encode()
}
// GetSession erstellt eine Session aus einem Auth-Token.
func (c *Client) GetSession(token string) (sessionKey, username string, err error) {
params := map[string]string{
"method": "auth.getSession",
"api_key": c.APIKey,
"token": token,
}
resp, err := c.signedRequest(params)
if err != nil {
return "", "", err
}
var result struct {
Session struct {
Key string `json:"key"`
Name string `json:"name"`
} `json:"session"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return "", "", err
}
return result.Session.Key, result.Session.Name, nil
}
// UpdateNowPlaying sendet "Now Playing" an Last.fm.
func (c *Client) UpdateNowPlaying(sessionKey, artist, track, album string) error {
params := map[string]string{
"method": "track.updateNowPlaying",
"api_key": c.APIKey,
"sk": sessionKey,
"artist": artist,
"track": track,
}
if album != "" {
params["album"] = album
}
_, err := c.signedPostRequest(params)
return err
}
// Scrobble sendet einen gespielten Track an Last.fm.
func (c *Client) Scrobble(sessionKey, artist, track, album string, timestamp time.Time) error {
params := map[string]string{
"method": "track.scrobble",
"api_key": c.APIKey,
"sk": sessionKey,
"artist": artist,
"track": track,
"timestamp": fmt.Sprintf("%d", timestamp.Unix()),
}
if album != "" {
params["album"] = album
}
_, err := c.signedPostRequest(params)
return err
}
func (c *Client) signedRequest(params map[string]string) ([]byte, error) {
params["api_sig"] = c.sign(params)
params["format"] = "json"
query := url.Values{}
for k, v := range params {
query.Set(k, v)
}
resp, err := c.HTTPClient.Get(apiBaseURL + "?" + query.Encode())
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (c *Client) signedPostRequest(params map[string]string) ([]byte, error) {
params["api_sig"] = c.sign(params)
params["format"] = "json"
form := url.Values{}
for k, v := range params {
form.Set(k, v)
}
resp, err := c.HTTPClient.PostForm(apiBaseURL, form)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func (c *Client) sign(params map[string]string) string {
// Alphabetisch sortierte Parameter + Secret = MD5
keys := make([]string, 0, len(params))
for k := range params {
if k != "format" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var sig strings.Builder
for _, k := range keys {
sig.WriteString(k)
sig.WriteString(params[k])
}
sig.WriteString(c.APISecret)
return fmt.Sprintf("%x", md5.Sum([]byte(sig.String())))
}

View File

@@ -0,0 +1,85 @@
package middleware
import (
"context"
"net/http"
"strings"
"streamdock/internal/auth"
)
type contextKey string
const (
UserIDKey contextKey = "user_id"
UsernameKey contextKey = "username"
RoleKey contextKey = "role"
)
// AuthMiddleware prüft JWT-Tokens.
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Token aus Header oder Cookie lesen
tokenString := extractToken(r)
if tokenString == "" {
http.Error(w, `{"error":"Authentifizierung erforderlich"}`, http.StatusUnauthorized)
return
}
claims, err := auth.ValidateToken(tokenString, jwtSecret)
if err != nil {
http.Error(w, `{"error":"Ungültiges Token"}`, http.StatusUnauthorized)
return
}
// Benutzerinformationen in Context setzen
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
ctx = context.WithValue(ctx, UsernameKey, claims.Username)
ctx = context.WithValue(ctx, RoleKey, claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// AdminOnly erlaubt nur Admin-Zugriff.
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(RoleKey).(string)
if !ok || role != "admin" {
http.Error(w, `{"error":"Admin-Rechte erforderlich"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// GetUserID liest die UserID aus dem Context.
func GetUserID(ctx context.Context) int64 {
id, _ := ctx.Value(UserIDKey).(int64)
return id
}
// GetRole liest die Rolle aus dem Context.
func GetRole(ctx context.Context) string {
role, _ := ctx.Value(RoleKey).(string)
return role
}
func extractToken(r *http.Request) string {
// 1. Authorization Header
bearer := r.Header.Get("Authorization")
if strings.HasPrefix(bearer, "Bearer ") {
return strings.TrimPrefix(bearer, "Bearer ")
}
// 2. Cookie
cookie, err := r.Cookie("streamdock_token")
if err == nil {
return cookie.Value
}
// 3. Query-Parameter (nur für WebSocket/Streaming)
return r.URL.Query().Get("token")
}

134
internal/models/models.go Normal file
View File

@@ -0,0 +1,134 @@
package models
import "time"
// User repräsentiert einen Benutzer.
type User struct {
ID int64 `db:"id" json:"id"`
Username string `db:"username" json:"username"`
Email string `db:"email" json:"email"`
PasswordHash string `db:"password_hash" json:"-"`
Role string `db:"role" json:"role"` // "admin" oder "user"
AvatarPath string `db:"avatar_path" json:"avatar_path"`
StorageQuota int64 `db:"storage_quota" json:"storage_quota"` // Bytes, 0 = unbegrenzt
StorageUsed int64 `db:"storage_used" json:"storage_used"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// UserSettings sind benutzerspezifische Einstellungen.
type UserSettings struct {
ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"`
// Last.fm
LastFMSessionKey string `db:"lastfm_session_key" json:"-"`
LastFMUsername string `db:"lastfm_username" json:"lastfm_username"`
// Plik
PlikURL string `db:"plik_url" json:"plik_url"`
PlikAPIKey string `db:"plik_api_key" json:"-"`
// Benachrichtigungen
NotifyEmail bool `db:"notify_email" json:"notify_email"`
NotifyEmailAddr string `db:"notify_email_addr" json:"notify_email_addr"`
NotifyWebhook bool `db:"notify_webhook" json:"notify_webhook"`
NotifyWebhookURL string `db:"notify_webhook_url" json:"notify_webhook_url"`
NotifyNtfy bool `db:"notify_ntfy" json:"notify_ntfy"`
NotifyNtfyTopic string `db:"notify_ntfy_topic" json:"notify_ntfy_topic"`
NotifyNtfyServer string `db:"notify_ntfy_server" json:"notify_ntfy_server"`
// Events
NotifyOnLogin bool `db:"notify_on_login" json:"notify_on_login"`
NotifyOnRecStart bool `db:"notify_on_rec_start" json:"notify_on_rec_start"`
NotifyOnRecEnd bool `db:"notify_on_rec_end" json:"notify_on_rec_end"`
NotifyOnRecError bool `db:"notify_on_rec_error" json:"notify_on_rec_error"`
// Player
Volume int `db:"volume" json:"volume"`
}
// Stream repräsentiert einen gespeicherten Stream.
type Stream struct {
ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
URL string `db:"url" json:"url"`
StreamType string `db:"stream_type" json:"stream_type"` // "audio" oder "video"
ContentType string `db:"content_type" json:"content_type"`
Bitrate int `db:"bitrate" json:"bitrate"`
SampleRate int `db:"sample_rate" json:"sample_rate"`
Genre string `db:"genre" json:"genre"`
Description string `db:"description" json:"description"`
LogoURL string `db:"logo_url" json:"logo_url"`
IsValid bool `db:"is_valid" json:"is_valid"`
LastChecked time.Time `db:"last_checked" json:"last_checked"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ScheduledRecording repräsentiert eine geplante Aufnahme.
type ScheduledRecording struct {
ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"`
StreamID int64 `db:"stream_id" json:"stream_id"`
CronExpr string `db:"cron_expr" json:"cron_expr"` // Cron-Ausdruck oder leer für einmalig
StartTime time.Time `db:"start_time" json:"start_time"` // Nächster/geplanter Start
Duration int `db:"duration" json:"duration"` // Dauer in Sekunden
IsRecurring bool `db:"is_recurring" json:"is_recurring"`
IsActive bool `db:"is_active" json:"is_active"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Recording repräsentiert eine abgeschlossene Aufnahme.
type Recording struct {
ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"`
StreamID int64 `db:"stream_id" json:"stream_id"`
StreamName string `db:"stream_name" json:"stream_name"`
FilePath string `db:"file_path" json:"file_path"`
FileName string `db:"file_name" json:"file_name"`
FileSize int64 `db:"file_size" json:"file_size"`
Duration int `db:"duration" json:"duration"` // Sekunden
Format string `db:"format" json:"format"`
Status string `db:"status" json:"status"` // "recording", "completed", "error"
ShareToken string `db:"share_token" json:"share_token,omitempty"`
StartedAt time.Time `db:"started_at" json:"started_at"`
StoppedAt *time.Time `db:"stopped_at" json:"stopped_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// ShareLink repräsentiert einen Teilen-Link für eine Aufnahme.
type ShareLink struct {
ID int64 `db:"id" json:"id"`
RecordingID int64 `db:"recording_id" json:"recording_id"`
UserID int64 `db:"user_id" json:"user_id"`
Token string `db:"token" json:"token"`
ExpiresAt *time.Time `db:"expires_at" json:"expires_at"` // nil = kein Ablauf
MaxDownloads int `db:"max_downloads" json:"max_downloads"` // 0 = unbegrenzt
Downloads int `db:"downloads" json:"downloads"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// NowPlaying enthält aktuelle Titelinformationen eines Streams.
type NowPlaying struct {
StreamID int64 `json:"stream_id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Station string `json:"station"`
HasMetadata bool `json:"has_metadata"`
}
// LoginRequest ist die Anfrage für den Login.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// TokenResponse ist die Antwort mit JWT-Token.
type TokenResponse struct {
Token string `json:"token"`
ExpiresAt int64 `json:"expires_at"`
User User `json:"user"`
}

View File

@@ -0,0 +1,141 @@
package notifications
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"time"
"streamdock/internal/config"
)
// EventType definiert den Typ eines Benachrichtigungs-Events.
type EventType string
const (
EventLogin EventType = "login"
EventRecStart EventType = "rec_start"
EventRecEnd EventType = "rec_end"
EventRecError EventType = "rec_error"
)
// Notification enthält die Daten einer Benachrichtigung.
type Notification struct {
Event EventType
Title string
Message string
}
// Notifier versendet Benachrichtigungen über verschiedene Kanäle.
type Notifier struct {
Config *config.Config
Client *http.Client
}
// NewNotifier erstellt einen neuen Notifier.
func NewNotifier(cfg *config.Config) *Notifier {
return &Notifier{
Config: cfg,
Client: &http.Client{Timeout: 10 * time.Second},
}
}
// SendEmail versendet eine E-Mail.
func (n *Notifier) SendEmail(to, subject, body string) error {
if n.Config.SMTPHost == "" {
return fmt.Errorf("SMTP nicht konfiguriert")
}
from := n.Config.SMTPFrom
addr := fmt.Sprintf("%s:%s", n.Config.SMTPHost, n.Config.SMTPPort)
msg := fmt.Sprintf("From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
n.Config.SMTPFromName, from, to, subject, body)
var auth smtp.Auth
if n.Config.SMTPUser != "" {
auth = smtp.PlainAuth("", n.Config.SMTPUser, n.Config.SMTPPassword, n.Config.SMTPHost)
}
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
}
// SendWebhook sendet eine Webhook-Benachrichtigung.
func (n *Notifier) SendWebhook(webhookURL string, notification Notification) error {
payload, err := json.Marshal(map[string]string{
"event": string(notification.Event),
"title": notification.Title,
"message": notification.Message,
})
if err != nil {
return err
}
req, err := http.NewRequest("POST", webhookURL, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := n.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("Webhook Fehler: HTTP %d", resp.StatusCode)
}
return nil
}
// SendNtfy sendet eine Ntfy-Benachrichtigung.
func (n *Notifier) SendNtfy(server, topic string, notification Notification) error {
if server == "" {
server = n.Config.NtfyDefaultServer
}
ntfyURL := fmt.Sprintf("%s/%s", server, topic)
req, err := http.NewRequest("POST", ntfyURL, bytes.NewReader([]byte(notification.Message)))
if err != nil {
return err
}
req.Header.Set("Title", notification.Title)
req.Header.Set("Tags", string(notification.Event))
resp, err := n.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("Ntfy Fehler: HTTP %d", resp.StatusCode)
}
return nil
}
// Notify sendet eine Benachrichtigung über alle konfigurierten Kanäle eines Benutzers.
func (n *Notifier) Notify(notification Notification, emailAddr, webhookURL, ntfyServer, ntfyTopic string, channels map[string]bool) {
if channels["email"] && emailAddr != "" {
if err := n.SendEmail(emailAddr, notification.Title, notification.Message); err != nil {
log.Printf("Email-Benachrichtigung fehlgeschlagen: %v", err)
}
}
if channels["webhook"] && webhookURL != "" {
if err := n.SendWebhook(webhookURL, notification); err != nil {
log.Printf("Webhook-Benachrichtigung fehlgeschlagen: %v", err)
}
}
if channels["ntfy"] && ntfyTopic != "" {
if err := n.SendNtfy(ntfyServer, ntfyTopic, notification); err != nil {
log.Printf("Ntfy-Benachrichtigung fehlgeschlagen: %v", err)
}
}
}

122
internal/plik/plik.go Normal file
View File

@@ -0,0 +1,122 @@
package plik
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// Client ist der Plik API-Client.
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
}
// UploadResult enthält die Ergebnis-URLs nach dem Upload.
type UploadResult struct {
UploadID string `json:"id"`
DownloadURL string `json:"download_url"`
}
// NewClient erstellt einen neuen Plik Client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: &http.Client{Timeout: 120 * time.Second},
}
}
// UploadFile lädt eine Datei zu Plik hoch.
func (c *Client) UploadFile(filePath string) (*UploadResult, error) {
// 1. Upload erstellen
uploadID, err := c.createUpload()
if err != nil {
return nil, fmt.Errorf("Upload erstellen fehlgeschlagen: %w", err)
}
// 2. Datei hochladen
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("Datei öffnen fehlgeschlagen: %w", err)
}
defer file.Close()
fileName := filepath.Base(filePath)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return nil, err
}
if _, err := io.Copy(part, file); err != nil {
return nil, err
}
writer.Close()
uploadURL := fmt.Sprintf("%s/file/%s", c.BaseURL, uploadID)
req, err := http.NewRequest("POST", uploadURL, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
if c.APIKey != "" {
req.Header.Set("X-PlikToken", c.APIKey)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Plik Upload Fehler: HTTP %d: %s", resp.StatusCode, string(body))
}
return &UploadResult{
UploadID: uploadID,
DownloadURL: fmt.Sprintf("%s/file/%s/%s/%s", c.BaseURL, uploadID, "0", fileName),
}, nil
}
func (c *Client) createUpload() (string, error) {
payload, _ := json.Marshal(map[string]interface{}{
"oneShot": false,
"ttl": 86400 * 7, // 7 Tage
})
req, err := http.NewRequest("POST", c.BaseURL+"/upload", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
if c.APIKey != "" {
req.Header.Set("X-PlikToken", c.APIKey)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.ID, nil
}

View File

@@ -0,0 +1,124 @@
package radiobrowser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// Station repräsentiert einen Radiosender aus der Radio-Browser API.
type Station struct {
ID string `json:"stationuuid"`
Name string `json:"name"`
URL string `json:"url_resolved"`
Homepage string `json:"homepage"`
Favicon string `json:"favicon"`
Country string `json:"country"`
CountryCode string `json:"countrycode"`
Language string `json:"language"`
Tags string `json:"tags"`
Codec string `json:"codec"`
Bitrate int `json:"bitrate"`
Votes int `json:"votes"`
}
// Client ist der Radio-Browser API-Client.
// Nutzt die freie API von https://www.radio-browser.info
type Client struct {
BaseURL string
HTTPClient *http.Client
}
// NewClient erstellt einen neuen Radio-Browser Client.
func NewClient() *Client {
return &Client{
BaseURL: "https://de1.api.radio-browser.info/json",
HTTPClient: &http.Client{Timeout: 15 * time.Second},
}
}
// Search sucht nach Radiosendern.
func (c *Client) Search(name, country, tag string, limit int) ([]Station, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
params := url.Values{
"limit": {fmt.Sprintf("%d", limit)},
"order": {"votes"},
"reverse": {"true"},
"hidebroken": {"true"},
}
if name != "" {
params.Set("name", name)
}
if country != "" {
params.Set("country", country)
}
if tag != "" {
params.Set("tag", tag)
}
reqURL := fmt.Sprintf("%s/stations/search?%s", c.BaseURL, params.Encode())
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "StreamDock/1.0")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var stations []Station
if err := json.Unmarshal(body, &stations); err != nil {
return nil, err
}
return stations, nil
}
// TopStations gibt die beliebtesten Sender zurück.
func (c *Client) TopStations(limit int) ([]Station, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
reqURL := fmt.Sprintf("%s/stations/topvote/%d", c.BaseURL, limit)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "StreamDock/1.0")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var stations []Station
if err := json.Unmarshal(body, &stations); err != nil {
return nil, err
}
return stations, nil
}

View File

@@ -0,0 +1,240 @@
package recorder
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
var ffmpegAvailable bool
func init() {
_, err := exec.LookPath("ffmpeg")
ffmpegAvailable = err == nil
if ffmpegAvailable {
log.Println("FFmpeg gefunden wird für Aufnahmen verwendet")
} else {
log.Println("FFmpeg nicht gefunden Fallback auf HTTP-Stream-Kopie")
}
}
// ActiveRecording repräsentiert eine laufende Aufnahme.
type ActiveRecording struct {
ID int64
StreamURL string
FilePath string
StartedAt time.Time
Cancel context.CancelFunc
}
// Recorder verwaltet Stream-Aufnahmen.
type Recorder struct {
basePath string
mu sync.RWMutex
recordings map[int64]*ActiveRecording
}
// New erstellt einen neuen Recorder.
func New(basePath string) *Recorder {
return &Recorder{
basePath: basePath,
recordings: make(map[int64]*ActiveRecording),
}
}
// Start beginnt eine Aufnahme und gibt den Dateipfad zurück.
func (r *Recorder) Start(recordingID int64, streamURL, streamName, streamType, username string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.recordings[recordingID]; exists {
return "", fmt.Errorf("Aufnahme %d läuft bereits", recordingID)
}
// Verzeichnis pro Benutzer und Stream
safeUser := sanitizeFilename(username)
safeStream := sanitizeFilename(streamName)
streamDir := filepath.Join(r.basePath, safeUser, safeStream)
if err := os.MkdirAll(streamDir, 0750); err != nil {
return "", err
}
// Dateiname erstellen
ext := ".mp3"
if streamType == "video" {
ext = ".ts"
}
timestamp := time.Now().Format("2006-01-02_15-04-05")
fileName := fmt.Sprintf("%s_%s%s", safeStream, timestamp, ext)
filePath := filepath.Join(streamDir, fileName)
ctx, cancel := context.WithCancel(context.Background())
active := &ActiveRecording{
ID: recordingID,
StreamURL: streamURL,
FilePath: filePath,
StartedAt: time.Now(),
Cancel: cancel,
}
r.recordings[recordingID] = active
// Aufnahme in Goroutine starten
go r.record(ctx, active)
return filePath, nil
}
// Stop beendet eine laufende Aufnahme.
func (r *Recorder) Stop(recordingID int64) error {
r.mu.Lock()
defer r.mu.Unlock()
active, exists := r.recordings[recordingID]
if !exists {
return fmt.Errorf("Aufnahme %d nicht gefunden", recordingID)
}
active.Cancel()
delete(r.recordings, recordingID)
return nil
}
// StartWithDuration startet eine Aufnahme die nach einer bestimmten Dauer stoppt.
func (r *Recorder) StartWithDuration(recordingID int64, streamURL, streamName, streamType, username string, duration time.Duration) (string, error) {
filePath, err := r.Start(recordingID, streamURL, streamName, streamType, username)
if err != nil {
return "", err
}
go func() {
time.Sleep(duration)
r.Stop(recordingID)
}()
return filePath, nil
}
// IsRecording prüft ob eine Aufnahme läuft.
func (r *Recorder) IsRecording(recordingID int64) bool {
r.mu.RLock()
defer r.mu.RUnlock()
_, exists := r.recordings[recordingID]
return exists
}
// ActiveCount gibt die Anzahl laufender Aufnahmen zurück.
func (r *Recorder) ActiveCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.recordings)
}
func (r *Recorder) record(ctx context.Context, active *ActiveRecording) {
if ffmpegAvailable {
r.recordWithFFmpeg(ctx, active)
} else {
r.recordWithHTTP(ctx, active)
}
}
func (r *Recorder) recordWithFFmpeg(ctx context.Context, active *ActiveRecording) {
args := []string{
"-y",
"-user_agent", "StreamDock/1.0",
"-i", active.StreamURL,
"-c", "copy",
"-f", guessFFmpegFormat(active.FilePath),
active.FilePath,
}
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
cmd.Stdout = nil
cmd.Stderr = nil
log.Printf("Aufnahme %d: FFmpeg gestartet -> %s", active.ID, active.FilePath)
err := cmd.Run()
if err != nil && ctx.Err() != context.Canceled {
log.Printf("Aufnahme %d: FFmpeg-Fehler: %v", active.ID, err)
return
}
log.Printf("Aufnahme %d: Beendet", active.ID)
}
func (r *Recorder) recordWithHTTP(ctx context.Context, active *ActiveRecording) {
req, err := http.NewRequestWithContext(ctx, "GET", active.StreamURL, nil)
if err != nil {
log.Printf("Aufnahme %d: Request-Fehler: %v", active.ID, err)
return
}
req.Header.Set("User-Agent", "StreamDock/1.0")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() == context.Canceled {
log.Printf("Aufnahme %d: Gestoppt", active.ID)
return
}
log.Printf("Aufnahme %d: Verbindungsfehler: %v", active.ID, err)
return
}
defer resp.Body.Close()
file, err := os.Create(active.FilePath)
if err != nil {
log.Printf("Aufnahme %d: Dateifehler: %v", active.ID, err)
return
}
defer file.Close()
log.Printf("Aufnahme %d: HTTP-Aufnahme gestartet -> %s", active.ID, active.FilePath)
_, err = io.Copy(file, resp.Body)
if err != nil && ctx.Err() != context.Canceled {
log.Printf("Aufnahme %d: Fehler beim Schreiben: %v", active.ID, err)
}
log.Printf("Aufnahme %d: Beendet", active.ID)
}
func guessFFmpegFormat(filePath string) string {
switch strings.ToLower(filepath.Ext(filePath)) {
case ".mp3":
return "mp3"
case ".ts":
return "mpegts"
case ".aac":
return "adts"
case ".ogg":
return "ogg"
case ".flac":
return "flac"
default:
return "mp3"
}
}
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"/", "_", "\\", "_", ":", "_", "*", "_",
"?", "_", "\"", "_", "<", "_", ">", "_",
"|", "_", " ", "_",
)
clean := replacer.Replace(name)
if len(clean) > 100 {
clean = clean[:100]
}
return clean
}

View File

@@ -0,0 +1,114 @@
package recorder
import (
"os"
"testing"
)
func TestSanitizeFilename(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"My Radio", "My_Radio"},
{"Rock/Pop", "Rock_Pop"},
{"test:file", "test_file"},
{"normal", "normal"},
{"a<b>c|d", "a_b_c_d"},
}
for _, tt := range tests {
got := sanitizeFilename(tt.input)
if got != tt.expected {
t.Errorf("sanitizeFilename(%q) = %q, erwartet %q", tt.input, got, tt.expected)
}
}
}
func TestSanitizeFilename_MaxLength(t *testing.T) {
long := ""
for i := 0; i < 150; i++ {
long += "a"
}
result := sanitizeFilename(long)
if len(result) > 100 {
t.Errorf("Länge = %d, erwartet <= 100", len(result))
}
}
func TestGuessFFmpegFormat(t *testing.T) {
tests := []struct {
path string
expected string
}{
{"/tmp/file.mp3", "mp3"},
{"/tmp/file.ts", "mpegts"},
{"/tmp/file.aac", "adts"},
{"/tmp/file.ogg", "ogg"},
{"/tmp/file.flac", "flac"},
{"/tmp/file.xyz", "mp3"},
}
for _, tt := range tests {
got := guessFFmpegFormat(tt.path)
if got != tt.expected {
t.Errorf("guessFFmpegFormat(%q) = %q, erwartet %q", tt.path, got, tt.expected)
}
}
}
func TestNewRecorder(t *testing.T) {
r := New("/tmp/test")
if r.basePath != "/tmp/test" {
t.Errorf("basePath = %q, erwartet /tmp/test", r.basePath)
}
if r.ActiveCount() != 0 {
t.Error("Neue Recorder-Instanz sollte 0 aktive Aufnahmen haben")
}
}
func TestStartAndStopRecording(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "streamdock_rec_test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
r := New(tmpDir)
// Start mit nicht erreichbarer URL (wird sofort fehlschlagen, aber die Logik testen)
filePath, err := r.Start(1, "http://127.0.0.1:1/nonexistent", "TestStream", "audio", "testuser")
if err != nil {
t.Fatalf("Start fehlgeschlagen: %v", err)
}
if filePath == "" {
t.Error("Dateipfad darf nicht leer sein")
}
if !r.IsRecording(1) {
t.Error("Aufnahme 1 sollte laufen")
}
if r.ActiveCount() != 1 {
t.Errorf("ActiveCount = %d, erwartet 1", r.ActiveCount())
}
// Doppelstart
_, err = r.Start(1, "http://127.0.0.1:1/nonexistent", "TestStream", "audio", "testuser")
if err == nil {
t.Error("Doppelstart sollte Fehler liefern")
}
// Stop
err = r.Stop(1)
if err != nil {
t.Fatalf("Stop fehlgeschlagen: %v", err)
}
if r.IsRecording(1) {
t.Error("Aufnahme 1 sollte nicht mehr laufen")
}
// Stop nicht existierende Aufnahme
err = r.Stop(999)
if err == nil {
t.Error("Stop einer nicht existierenden Aufnahme sollte Fehler liefern")
}
}

View File

@@ -0,0 +1,86 @@
package scheduler
import (
"log"
"github.com/robfig/cron/v3"
"streamdock/internal/db"
"streamdock/internal/recorder"
)
// JobFunc ist eine Funktion, die für eine geplante Aufnahme-ID ausgeführt wird.
type JobFunc func(scheduleID int64)
// Scheduler verwaltet zeitgesteuerte Aufnahmen.
type Scheduler struct {
cron *cron.Cron
db *db.DB
recorder *recorder.Recorder
jobFunc JobFunc
}
// New erstellt einen neuen Scheduler.
func New(database *db.DB, rec *recorder.Recorder) *Scheduler {
return &Scheduler{
cron: cron.New(cron.WithSeconds()),
db: database,
recorder: rec,
}
}
// SetJobFunc setzt die Callback-Funktion für geplante Aufnahmen.
func (s *Scheduler) SetJobFunc(fn JobFunc) {
s.jobFunc = fn
}
// Start startet den Scheduler und lädt alle aktiven Aufgaben.
func (s *Scheduler) Start() {
log.Println("Scheduler gestartet")
s.cron.Start()
if s.jobFunc == nil {
return
}
schedules, err := s.db.ListActiveSchedules()
if err != nil {
log.Printf("Fehler beim Laden aktiver Zeitpläne: %v", err)
return
}
for _, sched := range schedules {
if sched.IsRecurring && sched.CronExpr != "" {
schedID := sched.ID
_, err := s.AddSchedule(schedID, sched.CronExpr, func() {
s.jobFunc(schedID)
})
if err != nil {
log.Printf("Zeitplan %d konnte nicht geladen werden: %v", schedID, err)
}
}
}
log.Printf("%d aktive Zeitpläne geladen", len(schedules))
}
// Stop beendet den Scheduler.
func (s *Scheduler) Stop() {
log.Println("Scheduler gestoppt")
s.cron.Stop()
}
// AddSchedule fügt eine geplante Aufnahme hinzu.
func (s *Scheduler) AddSchedule(scheduleID int64, cronExpr string, job func()) (cron.EntryID, error) {
entryID, err := s.cron.AddFunc(cronExpr, job)
if err != nil {
return 0, err
}
log.Printf("Schedule %d hinzugefügt (Cron: %s, Entry: %d)", scheduleID, cronExpr, entryID)
return entryID, nil
}
// RemoveSchedule entfernt eine geplante Aufnahme.
func (s *Scheduler) RemoveSchedule(entryID cron.EntryID) {
s.cron.Remove(entryID)
log.Printf("Schedule Entry %d entfernt", entryID)
}

239
internal/stream/parser.go Normal file
View File

@@ -0,0 +1,239 @@
package stream
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// StreamInfo enthält die geparsten Informationen über einen Stream.
type StreamInfo struct {
URL string `json:"url"`
ContentType string `json:"content_type"`
StreamType string `json:"stream_type"` // "audio" oder "video"
Bitrate int `json:"bitrate"`
SampleRate int `json:"sample_rate"`
Name string `json:"name"`
Genre string `json:"genre"`
Description string `json:"description"`
IsValid bool `json:"is_valid"`
Error string `json:"error,omitempty"`
}
// MetadataInfo enthält aktuelle Titelinformationen (ICY metadata).
type MetadataInfo struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Station string `json:"station"`
HasMetadata bool `json:"has_metadata"` // true wenn der Stream ICY-Metadata unterstützt
}
// Parse analysiert einen Stream-URL und gibt Informationen zurück.
func Parse(streamURL string) (*StreamInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil)
if err != nil {
return &StreamInfo{URL: streamURL, IsValid: false, Error: err.Error()}, err
}
// ICY-Metadata anfordern
req.Header.Set("Icy-MetaData", "1")
req.Header.Set("User-Agent", "StreamDock/1.0")
client := &http.Client{
Timeout: 15 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("zu viele Weiterleitungen")
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return &StreamInfo{URL: streamURL, IsValid: false, Error: err.Error()}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &StreamInfo{URL: streamURL, IsValid: false, Error: fmt.Sprintf("HTTP %d", resp.StatusCode)}, nil
}
info := &StreamInfo{
URL: streamURL,
ContentType: resp.Header.Get("Content-Type"),
IsValid: true,
}
// Stream-Typ erkennen (Content-Type, dann URL als Fallback)
info.StreamType = detectStreamType(info.ContentType)
if info.StreamType == "audio" {
if urlType := detectStreamTypeByURL(streamURL); urlType != "" {
info.StreamType = urlType
}
}
// ICY-Header auslesen
if name := resp.Header.Get("icy-name"); name != "" {
info.Name = name
}
if genre := resp.Header.Get("icy-genre"); genre != "" {
info.Genre = genre
}
if desc := resp.Header.Get("icy-description"); desc != "" {
info.Description = desc
}
// Einige Bytes lesen, um sicherzustellen, dass der Stream Daten liefert
buf := make([]byte, 4096)
n, err := io.ReadAtLeast(resp.Body, buf, 1)
if err != nil || n == 0 {
info.IsValid = false
info.Error = "Stream liefert keine Daten"
}
return info, nil
}
// GetMetadata liest die aktuelle ICY-Metadata eines Streams.
// Es werden bis zu 5 Metadaten-Blöcke gelesen, da der erste Block oft leer ist.
func GetMetadata(streamURL string) (*MetadataInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", streamURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Icy-MetaData", "1")
req.Header.Set("User-Agent", "StreamDock/1.0")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
meta := &MetadataInfo{
Station: resp.Header.Get("icy-name"),
}
// ICY-Metaint lesen (Intervall der Metadata-Blöcke)
metaIntStr := resp.Header.Get("icy-metaint")
if metaIntStr == "" {
// Stream unterstützt kein ICY-Metadata
meta.HasMetadata = false
return meta, nil
}
var metaInt int
fmt.Sscanf(metaIntStr, "%d", &metaInt)
if metaInt <= 0 {
meta.HasMetadata = false
return meta, nil
}
// Stream unterstützt ICY-Metadata
meta.HasMetadata = true
// Bis zu 5 Metadaten-Blöcke lesen, da der erste oft leer ist
buf := make([]byte, metaInt)
lenBuf := make([]byte, 1)
for i := 0; i < 5; i++ {
// Audio-Daten bis zum Metadata-Block überspringen
if _, err := io.ReadFull(resp.Body, buf); err != nil {
return meta, nil
}
// Metadata-Länge lesen (1 Byte * 16 = Länge)
if _, err := io.ReadFull(resp.Body, lenBuf); err != nil {
return meta, nil
}
metaLen := int(lenBuf[0]) * 16
if metaLen == 0 {
continue
}
// Metadata lesen
metaBuf := make([]byte, metaLen)
if _, err := io.ReadFull(resp.Body, metaBuf); err != nil {
return meta, nil
}
// Metadata parsen: "StreamTitle='Artist - Title';"
metaStr := strings.TrimRight(string(metaBuf), "\x00")
title, artist := parseICYTitle(metaStr)
if title != "" {
meta.Title = title
meta.Artist = artist
return meta, nil
}
}
return meta, nil
}
func detectStreamType(contentType string) string {
ct := strings.ToLower(contentType)
switch {
case strings.Contains(ct, "video"),
strings.Contains(ct, "mpegurl"),
strings.Contains(ct, "x-flv"):
return "video"
default:
return "audio"
}
}
// detectStreamTypeByURL erkennt den Stream-Typ anhand der URL-Endung.
func detectStreamTypeByURL(url string) string {
lower := strings.ToLower(url)
// Query-Parameter entfernen
if idx := strings.Index(lower, "?"); idx != -1 {
lower = lower[:idx]
}
switch {
case strings.HasSuffix(lower, ".m3u8"),
strings.HasSuffix(lower, ".m3u"),
strings.HasSuffix(lower, ".flv"),
strings.HasSuffix(lower, ".mp4"),
strings.HasSuffix(lower, ".ts"),
strings.HasSuffix(lower, ".webm"):
return "video"
default:
return ""
}
}
func parseICYTitle(metadata string) (title, artist string) {
// Format: StreamTitle='Artist - Title';
start := strings.Index(metadata, "StreamTitle='")
if start == -1 {
return metadata, ""
}
start += len("StreamTitle='")
end := strings.Index(metadata[start:], "'")
if end == -1 {
return metadata[start:], ""
}
fullTitle := metadata[start : start+end]
// Versuche "Artist - Title" zu trennen
parts := strings.SplitN(fullTitle, " - ", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1]), strings.TrimSpace(parts[0])
}
return fullTitle, ""
}

View File

@@ -0,0 +1,350 @@
package videoproxy
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Session repräsentiert eine aktive Video-Proxy-Sitzung.
type Session struct {
ID string
StreamURL string // die tatsächliche Stream-URL (z.B. rtmp://...)
HLSDir string // Verzeichnis mit den HLS-Dateien
CreatedAt time.Time
cancel context.CancelFunc
cmd *exec.Cmd
ready chan struct{} // wird geschlossen, wenn die erste .m3u8 verfügbar ist
mu sync.Mutex
stopped bool
}
// Proxy verwaltet Video-Proxy-Sitzungen (RTMP → HLS via FFmpeg).
type Proxy struct {
basePath string
mu sync.RWMutex
sessions map[string]*Session
}
// New erstellt einen neuen Video-Proxy.
func New(basePath string) *Proxy {
dir := filepath.Join(basePath, "videoproxy")
os.MkdirAll(dir, 0750)
return &Proxy{
basePath: dir,
sessions: make(map[string]*Session),
}
}
// StartSession startet eine neue Proxy-Sitzung für die gegebene URL.
// Falls die URL auf eine .m3u-Playlist zeigt, wird sie zuerst geparst.
func (p *Proxy) StartSession(streamURL string) (*Session, error) {
// .m3u-Playlist parsen, um die eigentliche Stream-URL zu extrahieren
actualURL, err := resolveStreamURL(streamURL)
if err != nil {
return nil, fmt.Errorf("Stream-URL konnte nicht aufgelöst werden: %w", err)
}
p.mu.Lock()
defer p.mu.Unlock()
// Prüfen ob es schon eine Session für diese URL gibt
for _, s := range p.sessions {
if s.StreamURL == actualURL {
return s, nil
}
}
id := generateID()
hlsDir := filepath.Join(p.basePath, id)
if err := os.MkdirAll(hlsDir, 0750); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
session := &Session{
ID: id,
StreamURL: actualURL,
HLSDir: hlsDir,
CreatedAt: time.Now(),
cancel: cancel,
ready: make(chan struct{}),
}
p.sessions[id] = session
go p.runFFmpeg(ctx, session)
return session, nil
}
// StopSession beendet eine Proxy-Sitzung.
func (p *Proxy) StopSession(id string) {
p.mu.Lock()
session, exists := p.sessions[id]
if exists {
delete(p.sessions, id)
}
p.mu.Unlock()
if exists {
session.mu.Lock()
session.stopped = true
session.mu.Unlock()
session.cancel()
// Aufräumen: temporäre Dateien löschen
go func() {
time.Sleep(2 * time.Second)
os.RemoveAll(session.HLSDir)
}()
}
}
// GetSession gibt eine aktive Session zurück.
func (p *Proxy) GetSession(id string) *Session {
p.mu.RLock()
defer p.mu.RUnlock()
return p.sessions[id]
}
// WaitReady wartet, bis die HLS-Playlist bereit ist (max. 15 Sekunden).
func (s *Session) WaitReady(timeout time.Duration) bool {
select {
case <-s.ready:
return true
case <-time.After(timeout):
return false
}
}
// Cleanup bereinigt alle Sitzungen (für graceful shutdown).
func (p *Proxy) Cleanup() {
p.mu.Lock()
defer p.mu.Unlock()
for id, session := range p.sessions {
session.cancel()
os.RemoveAll(session.HLSDir)
delete(p.sessions, id)
}
}
// runFFmpeg startet FFmpeg zum Umwandeln von RTMP → HLS.
func (p *Proxy) runFFmpeg(ctx context.Context, session *Session) {
playlistPath := filepath.Join(session.HLSDir, "stream.m3u8")
args := []string{
"-y",
"-loglevel", "warning",
"-i", session.StreamURL,
"-c:v", "copy",
"-c:a", "aac",
"-b:a", "128k",
"-f", "hls",
"-hls_time", "4",
"-hls_list_size", "10",
"-hls_flags", "delete_segments+append_list",
"-hls_segment_filename", filepath.Join(session.HLSDir, "seg_%03d.ts"),
playlistPath,
}
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
session.mu.Lock()
session.cmd = cmd
session.mu.Unlock()
// Stderr lesen für Logging
stderr, _ := cmd.StderrPipe()
log.Printf("VideoProxy [%s]: Starte FFmpeg für %s", session.ID, session.StreamURL)
if err := cmd.Start(); err != nil {
log.Printf("VideoProxy [%s]: FFmpeg Start-Fehler: %v", session.ID, err)
p.StopSession(session.ID)
return
}
// Warte darauf, dass die .m3u8-Datei erstellt wird
go func() {
for i := 0; i < 150; i++ { // max. 15 Sekunden
if _, err := os.Stat(playlistPath); err == nil {
close(session.ready)
log.Printf("VideoProxy [%s]: HLS-Playlist bereit", session.ID)
return
}
time.Sleep(100 * time.Millisecond)
}
log.Printf("VideoProxy [%s]: Timeout beim Warten auf HLS-Playlist", session.ID)
}()
// Stderr in Hintergrund lesen, damit FFmpeg nicht blockiert
if stderr != nil {
go func() {
buf := make([]byte, 4096)
for {
n, err := stderr.Read(buf)
if n > 0 {
log.Printf("VideoProxy [%s]: %s", session.ID, string(buf[:n]))
}
if err != nil {
break
}
}
}()
}
err := cmd.Wait()
session.mu.Lock()
stopped := session.stopped
session.mu.Unlock()
if err != nil && !stopped && ctx.Err() == nil {
log.Printf("VideoProxy [%s]: FFmpeg beendet mit Fehler: %v", session.ID, err)
} else {
log.Printf("VideoProxy [%s]: FFmpeg beendet", session.ID)
}
// Session automatisch aufräumen
p.StopSession(session.ID)
}
// resolveStreamURL löst eine URL auf: Wenn sie auf eine .m3u-Playlist zeigt,
// wird die erste Stream-URL daraus extrahiert.
func resolveStreamURL(url string) (string, error) {
lower := strings.ToLower(url)
// Nur HTTP(S)-URLs als Playlists verarbeiten
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return url, nil
}
// Prüfen ob es eine .m3u-Playlist sein könnte
cleanURL := lower
if idx := strings.Index(cleanURL, "?"); idx != -1 {
cleanURL = cleanURL[:idx]
}
if !strings.HasSuffix(cleanURL, ".m3u") && !strings.HasSuffix(cleanURL, ".m3u8") {
return url, nil
}
// Playlist herunterladen und parsen
entries, err := ParseM3UPlaylist(url)
if err != nil {
return url, err
}
if len(entries) == 0 {
return url, fmt.Errorf("leere Playlist")
}
// Erste gültige URL zurückgeben
return entries[0].URL, nil
}
// M3UEntry repräsentiert einen Eintrag in einer M3U-Playlist.
type M3UEntry struct {
Name string
URL string
}
// ParseM3UPlaylist lädt und parst eine M3U-Playlist.
func ParseM3UPlaylist(playlistURL string) ([]M3UEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", playlistURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "StreamDock/1.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Maximal 1MB lesen
limited := io.LimitReader(resp.Body, 1<<20)
return parseM3UContent(limited)
}
// parseM3UContent parst den Inhalt einer M3U-Playlist.
func parseM3UContent(r io.Reader) ([]M3UEntry, error) {
var entries []M3UEntry
var currentName string
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line == "#EXTM3U" {
continue
}
if strings.HasPrefix(line, "#EXTINF:") {
// Format: #EXTINF:duration,name
if idx := strings.Index(line, ","); idx != -1 {
currentName = strings.TrimSpace(line[idx+1:])
}
continue
}
// Kommentare überspringen
if strings.HasPrefix(line, "#") {
continue
}
// Das ist eine URL-Zeile
if strings.Contains(line, "://") {
entries = append(entries, M3UEntry{
Name: currentName,
URL: line,
})
currentName = ""
}
}
return entries, scanner.Err()
}
// NeedsProxy prüft ob eine Stream-URL einen Proxy benötigt
// (d.h. ein Protokoll verwendet, das der Browser nicht nativ abspielen kann).
func NeedsProxy(streamURL string) bool {
lower := strings.ToLower(streamURL)
return strings.HasPrefix(lower, "rtmp://") ||
strings.HasPrefix(lower, "rtsp://") ||
strings.HasPrefix(lower, "mms://")
}
// NeedsProxyForPlaylist prüft ob eine Playlist-URL vermutlich einen Proxy braucht.
// Dazu wird die Playlist heruntergeladen und die erste URL geprüft.
func NeedsProxyForPlaylist(playlistURL string) (bool, string, error) {
entries, err := ParseM3UPlaylist(playlistURL)
if err != nil {
return false, "", err
}
if len(entries) == 0 {
return false, "", fmt.Errorf("leere Playlist")
}
firstURL := entries[0].URL
return NeedsProxy(firstURL), firstURL, nil
}
func generateID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}

97
tools/genicon.go Normal file
View File

@@ -0,0 +1,97 @@
//go:build ignore
// +build ignore
package main
import (
"image"
"image/color"
"image/draw"
"image/png"
"log"
"os"
"path/filepath"
)
func main() {
sizes := []int{192, 512}
outDir := filepath.Join("web", "static", "img")
os.MkdirAll(outDir, 0755)
bg := color.RGBA{15, 15, 26, 255} // --bg-primary
accent := color.RGBA{15, 155, 142, 255} // --accent
for _, size := range sizes {
img := image.NewRGBA(image.Rect(0, 0, size, size))
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// Draw a play triangle icon centered
cx, cy := size/2, size/2
triSize := size * 35 / 100 // 35% of icon size
// Simple filled play triangle (pointing right)
for y := cy - triSize; y <= cy+triSize; y++ {
// Width at this y: from 0 at top/bottom to triSize at center
dy := y - cy
if dy < 0 {
dy = -dy
}
width := triSize - dy
for x := cx - triSize/3; x < cx-triSize/3+width; x++ {
if x >= 0 && x < size && y >= 0 && y < size {
img.Set(x, y, accent)
}
}
}
// Draw a circular border/ring
radius := size * 42 / 100
thickness := size / 30
for angle := 0; angle < 3600; angle++ {
for r := radius - thickness; r <= radius; r++ {
// Simple circle via angle steps
ax := float64(angle) * 3.14159265 / 1800.0
px := cx + int(float64(r)*cosApprox(ax))
py := cy + int(float64(r)*sinApprox(ax))
if px >= 0 && px < size && py >= 0 && py < size {
img.Set(px, py, accent)
}
}
}
fname := filepath.Join(outDir, "icon-"+itoa(size)+".png")
f, err := os.Create(fname)
if err != nil {
log.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
log.Fatal(err)
}
f.Close()
log.Printf("Created %s", fname)
}
}
func sinApprox(x float64) float64 {
// Taylor series sin approximation
x2 := x * x
return x * (1 - x2/6 + x2*x2/120 - x2*x2*x2/5040)
}
func cosApprox(x float64) float64 {
x2 := x * x
return 1 - x2/2 + x2*x2/24 - x2*x2*x2/720
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}

30
web/pwa/manifest.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "StreamDock",
"short_name": "StreamDock",
"description": "Web-basierter Stream-Player und Recorder",
"start_url": "/",
"display": "standalone",
"background_color": "#0f0f1a",
"theme_color": "#1a1a2e",
"orientation": "any",
"icons": [
{
"src": "/static/img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/img/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/static/img/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["entertainment", "music"],
"lang": "de"
}

62
web/pwa/sw.js Normal file
View File

@@ -0,0 +1,62 @@
// StreamDock Service Worker
const CACHE_NAME = 'streamdock-v1';
const STATIC_ASSETS = [
'/',
'/static/css/style.css',
'/static/js/app.js',
'/static/js/player.js',
'/static/img/favicon.svg',
'/manifest.json',
];
// Installation: Statische Assets cachen
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
self.skipWaiting();
});
// Aktivierung: Alte Caches aufräumen
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((names) => {
return Promise.all(
names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
);
})
);
self.clients.claim();
});
// Fetch: Network-first mit Cache-Fallback für statische Assets
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API-Aufrufe nicht cachen
if (url.pathname.startsWith('/api/')) {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// Erfolgreiche Antwort cachen
if (response.ok && event.request.method === 'GET') {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, clone);
});
}
return response;
})
.catch(() => {
// Offline: Aus Cache bedienen
return caches.match(event.request).then((cached) => {
return cached || caches.match('/');
});
})
);
});

965
web/static/css/style.css Normal file
View File

@@ -0,0 +1,965 @@
/* ============================================
StreamDock - Stylesheet
============================================ */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-card: #16213e;
--bg-input: #1a1a3e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #0f9b8e;
--accent-hover: #12c4b4;
--danger: #e74c3c;
--warning: #f39c12;
--success: #2ecc71;
--border: #2a2a4a;
--radius: 8px;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
--player-height: 80px;
}
/* Reset */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
/* Navbar */
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.5rem;
height: 60px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
}
.navbar-logo {
width: 32px;
height: 32px;
}
.navbar-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent);
}
.navbar-toggle {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.navbar-toggle span {
display: block;
width: 24px;
height: 2px;
background: var(--text-primary);
transition: transform 0.3s;
}
.navbar-menu {
display: flex;
gap: 0.25rem;
}
.navbar-menu a {
color: var(--text-secondary);
text-decoration: none;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.9rem;
transition: all 0.2s;
}
.navbar-menu a:hover,
.navbar-menu a.active {
color: var(--accent);
background: rgba(15, 155, 142, 0.1);
}
.navbar-user {
display: flex;
align-items: center;
gap: 0.5rem;
}
.avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
/* Login */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
}
.login-card {
background: var(--bg-card);
padding: 2.5rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
width: 100%;
max-width: 400px;
text-align: center;
}
.login-logo {
width: 80px;
height: 80px;
margin-bottom: 1rem;
}
.login-card h1 {
margin-bottom: 1.5rem;
color: var(--accent);
}
/* Formulare */
.form-group {
margin-bottom: 1rem;
text-align: left;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
input[type="text"],
input[type="password"],
input[type="email"],
input[type="url"],
input[type="number"],
select,
textarea {
width: 100%;
padding: 0.625rem 0.75rem;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
font-weight: 500;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { opacity: 0.85; }
.btn-warning { background: var(--warning); color: #111; }
.btn-success { background: var(--success); color: white; }
.btn-ghost { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
.btn-ghost:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.btn-ghost.active { color: var(--accent); border-color: var(--accent); }
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.8rem; }
.btn-block { width: 100%; }
.btn-icon { background: transparent; color: var(--text-primary); font-size: 1.5rem; padding: 0.25rem 0.5rem; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Badge */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
border-radius: 12px;
background: var(--accent);
color: white;
}
.badge-secondary { background: var(--border); color: var(--text-secondary); }
.badge-recording { background: var(--danger); animation: pulse 1.5s infinite; }
.badge-completed { background: var(--success); }
.badge-error { background: var(--danger); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Main Content */
.main-content {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
padding-bottom: calc(var(--player-height) + 2rem);
}
.view h2 {
margin-bottom: 1.5rem;
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-card {
background: var(--bg-card);
padding: 1.5rem;
border-radius: var(--radius);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
/* Stream / Recording Cards */
.stream-list,
.recording-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.stream-card,
.recording-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.stream-info,
.recording-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.stream-logo {
width: 48px;
height: 48px;
border-radius: var(--radius);
object-fit: cover;
flex-shrink: 0;
}
.stream-info h3,
.recording-info h3 {
font-size: 1rem;
margin-bottom: 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.stream-actions,
.recording-actions {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
/* Search Bar */
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-bar input,
.search-bar select {
flex: 1;
min-width: 200px;
}
/* Player Bar */
.player-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--player-height);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1rem;
z-index: 200;
}
.player-info {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.player-logo {
width: 48px;
height: 48px;
border-radius: var(--radius);
object-fit: cover;
}
.player-text {
min-width: 0;
}
.player-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-artist {
font-size: 0.85rem;
color: var(--text-secondary);
}
.player-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.listen-timer {
font-size: 0.8rem;
color: var(--accent);
font-variant-numeric: tabular-nums;
min-width: 4.5rem;
text-align: center;
opacity: 0.85;
}
.volume-slider {
width: 100px;
accent-color: var(--accent);
}
.volume-label {
font-size: 0.8rem;
color: var(--text-secondary);
width: 3rem;
}
/* Equalizer */
.equalizer-panel {
position: fixed;
bottom: var(--player-height);
left: 0;
right: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
padding: 1rem 1.5rem;
z-index: 199;
}
.equalizer-panel h3 {
margin-bottom: 0.75rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.eq-sliders {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.eq-band {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.eq-slider {
writing-mode: vertical-lr;
direction: rtl;
height: 100px;
accent-color: var(--accent);
}
.eq-band label {
font-size: 0.7rem;
color: var(--text-secondary);
}
.eq-presets {
display: flex;
gap: 0.5rem;
justify-content: center;
}
/* Visualizer */
.visualizer-container {
position: fixed;
bottom: var(--player-height);
left: 0;
right: 0;
background: rgba(15, 15, 26, 0.95);
z-index: 198;
display: flex;
justify-content: center;
}
.visualizer-container canvas {
width: 100%;
max-width: 1200px;
height: 150px;
}
/* Video Overlay */
.video-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 300;
display: flex;
justify-content: center;
align-items: center;
}
.video-overlay video {
max-width: 90vw;
max-height: 80vh;
border-radius: var(--radius);
}
.video-close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 1.5rem;
color: white;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
/* Error Message */
.error-message {
color: var(--danger);
font-size: 0.9rem;
margin-top: 0.75rem;
}
/* ============================================
Modal
============================================ */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal h3 {
margin-bottom: 1.5rem;
font-size: 1.25rem;
}
.modal .form-group {
margin-bottom: 1rem;
}
.modal .form-group label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.modal .form-group input,
.modal .form-group select,
.modal .form-group textarea {
width: 100%;
padding: 0.6rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.95rem;
}
.modal .form-group select {
cursor: pointer;
}
.modal .form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* ============================================
Settings
============================================ */
.settings-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.settings-section h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.form-row {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row .form-group {
flex: 1;
}
.form-row .form-group label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.form-row .form-group input,
.form-row .form-group select {
width: 100%;
padding: 0.6rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.95rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
/* ============================================
Data Tables (Admin)
============================================ */
.table-container {
overflow-x: auto;
border-radius: 12px;
border: 1px solid var(--border);
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.data-table th {
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.data-table td {
font-size: 0.95rem;
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
/* ============================================
Schedule Cards
============================================ */
.schedule-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.schedule-info {
flex: 1;
}
.schedule-info strong {
display: block;
margin-bottom: 0.3rem;
}
.schedule-meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
flex-wrap: wrap;
}
.schedule-meta code {
background: var(--bg-primary);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
}
.schedule-actions {
display: flex;
gap: 0.5rem;
}
/* ============================================
Badges
============================================ */
.badge-success {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(72, 199, 142, 0.15);
color: var(--success);
}
.badge-warning {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(255, 183, 77, 0.15);
color: var(--warning);
}
.badge-secondary {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.08);
color: var(--text-secondary);
}
/* ============================================
Notification Channels (Settings)
============================================ */
.notification-channel {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.notification-channel h4 {
margin-bottom: 0.75rem;
font-size: 1rem;
}
.notification-channel label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.9rem;
color: var(--text-secondary);
cursor: pointer;
}
.notification-channel input[type="checkbox"] {
accent-color: var(--accent);
width: 16px;
height: 16px;
}
/* ============================================
Utility Classes
============================================ */
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mb-1 { margin-bottom: 0.5rem; }
.text-center { text-align: center; }
.text-small { font-size: 0.85rem; color: var(--text-secondary); }
select {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.5rem 0.75rem;
font-size: 0.95rem;
cursor: pointer;
}
code {
background: var(--bg-primary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-family: monospace;
font-size: 0.9rem;
}
/* Library results */
.library-results {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.library-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.library-item span {
color: var(--text-secondary);
font-size: 0.85rem;
}
/* Compact buttons */
.btn.compact {
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
}
/* ============================================
Responsive
============================================ */
@media (max-width: 768px) {
.navbar-toggle {
display: flex;
}
.navbar-menu {
display: none;
position: absolute;
top: 60px;
left: 0;
right: 0;
background: var(--bg-secondary);
flex-direction: column;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
.navbar-menu.is-active {
display: flex;
}
.navbar-user span {
display: none;
}
.stream-card,
.recording-card {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.stream-actions,
.recording-actions {
width: 100%;
flex-wrap: wrap;
}
.player-bar {
flex-wrap: wrap;
height: auto;
padding: 0.75rem;
gap: 0.5rem;
}
.player-extras {
display: flex;
gap: 0.25rem;
}
.volume-slider {
width: 60px;
}
.eq-slider {
height: 60px;
}
.eq-sliders {
gap: 0.5rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.modal {
padding: 1.25rem;
max-width: 100%;
}
.form-row {
flex-direction: column;
gap: 0;
}
.schedule-card {
flex-direction: column;
align-items: flex-start;
}
.schedule-actions {
width: 100%;
flex-wrap: wrap;
}
.data-table th,
.data-table td {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.search-bar {
flex-direction: column;
}
.search-bar input,
.search-bar select {
min-width: unset;
}
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<circle cx="50" cy="50" r="48" fill="#2a2a4a"/>
<circle cx="50" cy="38" r="16" fill="#555"/>
<ellipse cx="50" cy="75" rx="24" ry="16" fill="#555"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="12" fill="#2a2a4a"/>
<circle cx="50" cy="50" r="20" fill="none" stroke="#555" stroke-width="3"/>
<polygon points="44,40 44,60 62,50" fill="#555"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f9b8e;stop-opacity:1" />
<stop offset="100%" style="stop-color:#12c4b4;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="20" fill="#1a1a2e"/>
<!-- Play-Symbol -->
<polygon points="38,25 38,75 78,50" fill="url(#grad)" />
<!-- Wellenlinien -->
<line x1="22" y1="35" x2="22" y2="65" stroke="#0f9b8e" stroke-width="4" stroke-linecap="round" opacity="0.7"/>
<line x1="30" y1="28" x2="30" y2="72" stroke="#12c4b4" stroke-width="4" stroke-linecap="round" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

BIN
web/static/img/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
web/static/img/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

835
web/static/js/app.js Normal file
View File

@@ -0,0 +1,835 @@
/**
* StreamDock - Haupt-App (Alpine.js)
*/
function app() {
return {
// Auth
isAuthenticated: false,
user: null,
token: null,
loginForm: { username: '', password: '' },
loginLoading: false,
loginError: '',
// Navigation
currentView: 'dashboard',
menuOpen: false,
// UI State
showAddStream: false,
showEqualizer: false,
showVisualizer: false,
showAddSchedule: false,
showAddUser: false,
editingUser: null,
// Daten
stats: { streams: 0, recordings: 0, activeRecordings: 0, storageUsed: 0 },
streams: [],
recordings: [],
schedules: [],
radioStations: [],
radioSearch: '',
radioCountry: '',
librarySearch: '',
libraryResults: null,
adminUsers: [],
userSettings: {},
// Formulare
profileForm: { username: '', email: '' },
passwordForm: { old_password: '', new_password: '' },
avatarFile: null,
plikApiKeyInput: '',
streamForm: { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' },
editingStream: null,
scheduleForm: { stream_id: '', duration_min: 60, is_recurring: false, cron_expr: '', start_time: '' },
userForm: { username: '', email: '', password: '', role: 'user', storage_quota_mb: 0 },
// Player
player: {
active: false,
playing: false,
type: 'audio', // 'audio' oder 'video'
name: '',
title: '',
artist: '',
logo: '',
url: '',
volume: 80,
streamId: null,
listeningSince: null,
listenTime: '00:00:00',
},
// Equalizer Bänder
eqBands: [
{ freq: 60, label: '60Hz', gain: 0 },
{ freq: 170, label: '170Hz', gain: 0 },
{ freq: 350, label: '350Hz', gain: 0 },
{ freq: 1000, label: '1kHz', gain: 0 },
{ freq: 3500, label: '3.5kHz', gain: 0 },
{ freq: 10000, label: '10kHz', gain: 0 },
{ freq: 16000, label: '16kHz', gain: 0 },
],
// Initialisierung
init() {
// Debounce-Timer für Volume-Speicherung
this._volumeSaveTimer = null;
// Token aus localStorage prüfen
const savedToken = localStorage.getItem('streamdock_token');
if (savedToken) {
this.token = savedToken;
this.isAuthenticated = true;
this.loadProfile();
this.loadDashboard();
this.loadVolume();
}
// Last.fm Callback erkennen
const params = new URLSearchParams(window.location.search);
if (params.get('lastfm') === 'callback' && params.get('token')) {
const lastfmToken = params.get('token');
// URL aufräumen
window.history.replaceState({}, '', '/settings');
// Token an Backend senden
if (this.isAuthenticated) {
this.api('POST', '/api/lastfm/callback', { token: lastfmToken })
.then(() => {
this.loadSettings();
this.navigate('settings');
})
.catch(err => alert('Last.fm Verbindung fehlgeschlagen: ' + err.message));
}
}
// Media Session API Medientasten der Tastatur
this.initMediaSession();
},
initMediaSession() {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => {
if (this.player.active && !this.player.playing) {
this.playerToggle();
}
});
navigator.mediaSession.setActionHandler('pause', () => {
if (this.player.active && this.player.playing) {
this.playerToggle();
}
});
navigator.mediaSession.setActionHandler('stop', () => {
if (this.player.active) {
this.playerStop();
}
});
},
updateMediaSessionMetadata() {
if (!('mediaSession' in navigator)) return;
const title = this.player.title || this.player.name || 'StreamDock';
const artist = this.player.artist || '';
const artwork = this.player.logo
? [{ src: this.player.logo, sizes: '256x256', type: 'image/png' }]
: [];
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
album: 'StreamDock',
artwork,
});
navigator.mediaSession.playbackState = this.player.playing ? 'playing' : 'paused';
},
// Navigation
navigate(view) {
this.currentView = view;
this.menuOpen = false;
switch (view) {
case 'dashboard': this.loadDashboard(); break;
case 'streams': this.loadStreams(); break;
case 'recordings': this.loadRecordings(); break;
case 'radio': this.loadTopRadio(); break;
case 'schedules': this.loadSchedules(); this.loadStreams(); break;
case 'settings': this.loadSettings(); break;
case 'admin': this.loadAdminUsers(); break;
}
},
// Auth
async login() {
this.loginLoading = true;
this.loginError = '';
try {
const resp = await this.api('POST', '/api/auth/login', this.loginForm);
this.token = resp.token;
this.user = resp.user;
this.isAuthenticated = true;
localStorage.setItem('streamdock_token', resp.token);
this.loadDashboard();
this.loadVolume();
} catch (err) {
this.loginError = err.message || 'Anmeldung fehlgeschlagen';
} finally {
this.loginLoading = false;
}
},
logout() {
this.isAuthenticated = false;
this.token = null;
this.user = null;
localStorage.removeItem('streamdock_token');
this.playerStop();
},
// API-Aufrufe
async api(method, path, body) {
const opts = {
method,
headers: {
'Content-Type': 'application/json',
},
};
if (this.token) {
opts.headers['Authorization'] = 'Bearer ' + this.token;
}
if (body) {
opts.body = JSON.stringify(body);
}
const resp = await fetch(path, opts);
const data = await resp.json();
if (!resp.ok) {
if (resp.status === 401) {
this.logout();
}
throw new Error(data.error || 'API-Fehler');
}
return data;
},
// Daten laden
async loadProfile() {
try {
this.user = await this.api('GET', '/api/auth/me');
} catch { /* Token ungültig */ }
},
async loadDashboard() {
try {
this.stats = await this.api('GET', '/api/library/stats');
} catch { /* ignore */ }
},
async loadStreams() {
try {
this.streams = await this.api('GET', '/api/streams');
} catch { /* ignore */ }
},
async loadRecordings() {
try {
this.recordings = await this.api('GET', '/api/recordings');
} catch { /* ignore */ }
},
async loadTopRadio() {
try {
this.radioStations = await this.api('GET', '/api/radio-browser/search?limit=50');
} catch { /* ignore */ }
},
async searchRadio() {
try {
const params = new URLSearchParams();
if (this.radioSearch) params.set('name', this.radioSearch);
if (this.radioCountry) params.set('country', this.radioCountry);
params.set('limit', '50');
this.radioStations = await this.api('GET', '/api/radio-browser/search?' + params.toString());
} catch { /* ignore */ }
},
async searchLibrary() {
if (!this.librarySearch) return;
try {
this.libraryResults = await this.api('GET', '/api/library/search?q=' + encodeURIComponent(this.librarySearch));
} catch { /* ignore */ }
},
// Schedules
async loadSchedules() {
try {
this.schedules = await this.api('GET', '/api/schedules');
} catch { /* ignore */ }
},
async createSchedule() {
try {
await this.api('POST', '/api/schedules', {
stream_id: parseInt(this.scheduleForm.stream_id),
duration: this.scheduleForm.duration_min * 60,
is_recurring: this.scheduleForm.is_recurring,
cron_expr: this.scheduleForm.is_recurring ? this.scheduleForm.cron_expr : '',
start_time: this.scheduleForm.is_recurring ? null : this.scheduleForm.start_time,
});
this.showAddSchedule = false;
this.scheduleForm = { stream_id: '', duration_min: 60, is_recurring: false, cron_expr: '', start_time: '' };
this.loadSchedules();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async toggleSchedule(sched) {
try {
await this.api('PUT', `/api/schedules/${sched.id}`, { is_active: !sched.is_active });
this.loadSchedules();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async deleteSchedule(sched) {
if (!confirm('Zeitplan wirklich löschen?')) return;
try {
await this.api('DELETE', `/api/schedules/${sched.id}`);
this.loadSchedules();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
getStreamName(streamId) {
const s = this.streams.find(st => st.id === streamId);
return s ? s.name : 'Stream #' + streamId;
},
// Settings
async loadSettings() {
try {
this.userSettings = await this.api('GET', '/api/settings');
this.profileForm.username = this.user?.username || '';
this.profileForm.email = this.user?.email || '';
} catch { /* ignore */ }
},
async updateProfile() {
try {
this.user = await this.api('PUT', '/api/auth/me', this.profileForm);
alert('Profil aktualisiert');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async changePassword() {
try {
await this.api('PUT', '/api/auth/me/password', this.passwordForm);
this.passwordForm = { old_password: '', new_password: '' };
alert('Passwort geändert');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async uploadAvatar() {
if (!this.avatarFile) return;
const formData = new FormData();
formData.append('avatar', this.avatarFile);
try {
const resp = await fetch('/api/auth/me/avatar', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + this.token },
body: formData,
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error);
this.user.avatar_path = data.avatar_path;
this.avatarFile = null;
alert('Profilbild aktualisiert');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async connectLastFM() {
try {
const result = await this.api('GET', '/api/lastfm/auth-url');
window.open(result.url, '_blank');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async disconnectLastFM() {
this.userSettings.lastfm_username = '';
this.userSettings.lastfm_session_key = '';
await this.saveSettings();
},
async saveSettings() {
try {
const payload = { ...this.userSettings };
if (this.plikApiKeyInput) {
payload.plik_api_key = this.plikApiKeyInput;
}
this.userSettings = await this.api('PUT', '/api/settings', payload);
this.plikApiKeyInput = '';
alert('Einstellungen gespeichert');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
// Admin
async loadAdminUsers() {
try {
this.adminUsers = await this.api('GET', '/api/admin/users');
} catch { /* ignore */ }
},
async createUser() {
try {
await this.api('POST', '/api/admin/users', {
username: this.userForm.username,
email: this.userForm.email,
password: this.userForm.password,
role: this.userForm.role,
storage_quota: this.userForm.storage_quota_mb * 1024 * 1024,
});
this.showAddUser = false;
this.userForm = { username: '', email: '', password: '', role: 'user', storage_quota_mb: 0 };
this.loadAdminUsers();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
editUser(u) {
this.editingUser = u;
this.userForm = {
username: u.username,
email: u.email,
password: '',
role: u.role,
storage_quota_mb: Math.round(u.storage_quota / 1024 / 1024),
};
this.showAddUser = true;
},
async updateUser() {
try {
const payload = {
username: this.userForm.username,
email: this.userForm.email,
role: this.userForm.role,
};
if (this.userForm.password) payload.password = this.userForm.password;
await this.api('PUT', `/api/admin/users/${this.editingUser.id}`, payload);
if (this.userForm.storage_quota_mb >= 0) {
await this.api('PUT', `/api/admin/users/${this.editingUser.id}/quota`, {
storage_quota: this.userForm.storage_quota_mb * 1024 * 1024,
});
}
this.showAddUser = false;
this.editingUser = null;
this.userForm = { username: '', email: '', password: '', role: 'user', storage_quota_mb: 0 };
this.loadAdminUsers();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async deleteUser(u) {
if (!confirm('Benutzer "' + u.username + '" wirklich löschen?')) return;
try {
await this.api('DELETE', `/api/admin/users/${u.id}`);
this.loadAdminUsers();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
// Stream-Aktionen
playStream(stream) {
window.streamPlayer.play(stream.url, stream.name, stream.stream_type, stream.logo_url, stream.id, this.token);
this.player.active = true;
this.player.name = stream.name;
this.player.type = stream.stream_type;
this.player.logo = stream.logo_url;
this.player.url = stream.url;
this.player.playing = true;
this.player.streamId = stream.id;
this.startListenTimer();
this.startNowPlayingPoll();
this.updateMediaSessionMetadata();
},
playRadioStation(station) {
window.streamPlayer.play(station.url_resolved, station.name, 'audio', station.favicon, null, this.token);
this.player.active = true;
this.player.name = station.name;
this.player.type = 'audio';
this.player.logo = station.favicon;
this.player.url = station.url_resolved;
this.player.playing = true;
this.player.streamId = null;
this.startListenTimer();
this.startNowPlayingPoll();
this.updateMediaSessionMetadata();
},
async addRadioStation(station) {
try {
await this.api('POST', '/api/streams', {
name: station.name,
url: station.url_resolved,
stream_type: 'audio',
genre: station.tags,
logo_url: station.favicon,
description: station.country + ' · ' + station.codec + ' · ' + station.bitrate + 'kbps',
});
alert('Stream hinzugefügt!');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async recordStream(stream) {
try {
await this.api('POST', '/api/recordings/start', { stream_id: stream.id });
alert('Aufnahme gestartet!');
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async stopRecording(rec) {
try {
await this.api('POST', `/api/recordings/${rec.id}/stop`);
this.loadRecordings();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
playRecording(rec) {
const url = `/api/recordings/${rec.id}/stream?token=${this.token}`;
window.streamPlayer.play(url, rec.stream_name, rec.format?.includes('video') ? 'video' : 'audio', '', null, this.token);
this.player.active = true;
this.player.name = rec.stream_name;
this.player.playing = true;
this.startListenTimer();
this.updateMediaSessionMetadata();
},
downloadRecording(rec) {
window.open(`/api/recordings/${rec.id}/download?token=${this.token}`);
},
async shareRecording(rec) {
try {
const result = await this.api('POST', `/api/recordings/${rec.id}/share`);
const url = window.location.origin + '/api/share/' + result.token;
await navigator.clipboard.writeText(url);
alert('Link kopiert: ' + url);
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async sendToPlik(rec) {
try {
const result = await this.api('POST', `/api/recordings/${rec.id}/plik`);
alert('An Plik gesendet: ' + result.download_url);
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async deleteStream(stream) {
if (!confirm('Stream wirklich löschen?')) return;
try {
await this.api('DELETE', `/api/streams/${stream.id}`);
this.loadStreams();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async deleteRecording(rec) {
if (!confirm('Aufnahme wirklich löschen?')) return;
try {
await this.api('DELETE', `/api/recordings/${rec.id}`);
this.loadRecordings();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
async addStream() {
try {
await this.api('POST', '/api/streams', this.streamForm);
this.showAddStream = false;
this.streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' };
this.loadStreams();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
editStream(stream) {
this.editingStream = stream;
this.streamForm = {
name: stream.name,
url: stream.url,
stream_type: stream.stream_type || 'audio',
genre: stream.genre || '',
description: stream.description || '',
logo_url: stream.logo_url || '',
};
this.showAddStream = true;
},
async updateStream() {
try {
await this.api('PUT', `/api/streams/${this.editingStream.id}`, this.streamForm);
this.showAddStream = false;
this.editingStream = null;
this.streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' };
this.loadStreams();
} catch (err) {
alert('Fehler: ' + err.message);
}
},
// Player-Controls
playerToggle() {
if (this.player.playing) {
window.streamPlayer.pause();
this.player.playing = false;
} else {
window.streamPlayer.resume();
this.player.playing = true;
}
this.updateMediaSessionMetadata();
},
playerStop() {
window.streamPlayer.stop();
this.player.active = false;
this.player.playing = false;
this.showVisualizer = false;
this.showEqualizer = false;
this._streamHasMetadata = true;
this.stopListenTimer();
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none';
}
},
setVolume() {
window.streamPlayer.setVolume(this.player.volume / 100);
this.saveVolume();
},
// Hörzeit-Timer
startListenTimer() {
this.stopListenTimer();
this.player.listeningSince = Date.now();
this.player.listenTime = '00:00:00';
this._listenTimerInterval = setInterval(() => {
if (!this.player.listeningSince) return;
const elapsed = Math.floor((Date.now() - this.player.listeningSince) / 1000);
const h = String(Math.floor(elapsed / 3600)).padStart(2, '0');
const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0');
const s = String(elapsed % 60).padStart(2, '0');
this.player.listenTime = `${h}:${m}:${s}`;
}, 1000);
},
stopListenTimer() {
if (this._listenTimerInterval) {
clearInterval(this._listenTimerInterval);
this._listenTimerInterval = null;
}
this.player.listeningSince = null;
this.player.listenTime = '00:00:00';
},
// Scrollrad-Lautstärke
handlePlayerWheel(event) {
event.preventDefault();
const delta = event.deltaY < 0 ? 5 : -5;
this.player.volume = Math.max(0, Math.min(100, parseInt(this.player.volume) + delta));
this.setVolume();
},
// Lautstärke vom Server laden
async loadVolume() {
try {
const settings = await this.api('GET', '/api/settings');
if (settings.volume !== undefined && settings.volume >= 0 && settings.volume <= 100) {
this.player.volume = settings.volume;
window.streamPlayer?.setVolume?.(this.player.volume / 100);
}
} catch { /* ignore */ }
},
// Lautstärke debounced auf Server speichern
saveVolume() {
clearTimeout(this._volumeSaveTimer);
this._volumeSaveTimer = setTimeout(async () => {
try {
await this.api('PUT', '/api/settings/volume', { volume: parseInt(this.player.volume) });
} catch { /* ignore */ }
}, 500);
},
// Equalizer
toggleEqualizer() {
this.showEqualizer = !this.showEqualizer;
},
updateEQ(index) {
window.streamPlayer.setEQBand(index, parseFloat(this.eqBands[index].gain));
},
eqPreset(name) {
const presets = {
flat: [0, 0, 0, 0, 0, 0, 0],
bass: [6, 4, 2, 0, 0, 0, 0],
rock: [4, 2, -1, 0, 2, 4, 3],
vocal: [-2, 0, 2, 4, 2, 0, -2],
};
const values = presets[name] || presets.flat;
this.eqBands.forEach((band, i) => {
band.gain = values[i];
this.updateEQ(i);
});
},
// Visualizer
toggleVisualizer() {
this.showVisualizer = !this.showVisualizer;
if (this.showVisualizer) {
this.$nextTick(() => window.streamPlayer.startVisualizer('visualizer'));
} else {
window.streamPlayer.stopVisualizer();
}
},
// Now Playing / Last.fm
async fetchNowPlaying() {
if (this.player.streamId) {
return await this.api('GET', `/api/streams/${this.player.streamId}/now-playing`);
} else if (this.player.url) {
return await this.api('POST', '/api/streams/metadata', { url: this.player.url });
}
return null;
},
startNowPlayingPoll() {
if (this._nowPlayingInterval) clearInterval(this._nowPlayingInterval);
this._lastScrobbledTitle = '';
this._streamHasMetadata = true;
// Sofort Metadata laden
this.fetchNowPlaying().then(np => {
if (np) {
this._streamHasMetadata = np.has_metadata !== false;
if (np.title) {
this.player.title = np.title;
this.player.artist = np.artist;
this.updateMediaSessionMetadata();
}
}
}).catch(() => {});
// 10 Sekunden nach Start scrobbeln
setTimeout(async () => {
if (this.player.playing) {
try {
const np = await this.fetchNowPlaying();
if (np) {
this._streamHasMetadata = np.has_metadata !== false;
if (np.title) {
this.player.title = np.title;
this.player.artist = np.artist;
this.updateMediaSessionMetadata();
// Nur scrobbeln wenn Artist UND Title vorhanden sind
if (np.artist && np.title) {
const scrobbleKey = np.artist + ' - ' + np.title;
if (scrobbleKey !== this._lastScrobbledTitle) {
this._lastScrobbledTitle = scrobbleKey;
this.api('POST', '/api/lastfm/scrobble', {
artist: np.artist,
track: np.title,
album: np.album,
}).catch(() => {});
}
}
}
}
} catch { /* ignore */ }
}
}, 10000);
// Alle 30 Sekunden aktualisieren
this._nowPlayingInterval = setInterval(async () => {
if (this.player.playing) {
try {
const np = await this.fetchNowPlaying();
if (np) {
this._streamHasMetadata = np.has_metadata !== false;
if (np.title) {
this.player.title = np.title;
this.player.artist = np.artist;
this.updateMediaSessionMetadata();
// Scrobble bei Titelwechsel, nur wenn Artist UND Title vorhanden
if (np.artist && np.title) {
const scrobbleKey = np.artist + ' - ' + np.title;
if (scrobbleKey !== this._lastScrobbledTitle) {
this._lastScrobbledTitle = scrobbleKey;
this.api('POST', '/api/lastfm/scrobble', {
artist: np.artist,
track: np.title,
album: np.album,
}).catch(() => {});
}
}
}
}
} catch { /* ignore */ }
}
}, 30000);
},
// Hilfsfunktionen
formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
},
formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleString('de-DE');
},
};
}

300
web/static/js/player.js Normal file
View File

@@ -0,0 +1,300 @@
/**
* StreamDock - Audio/Video Player mit Equalizer und Visualizer
* Nutzt die Web Audio API für Equalizer und Visualizer.
*/
class StreamPlayer {
constructor() {
this.audioEl = null;
this.videoEl = null;
this.audioContext = null;
this.analyser = null;
this.sourceNode = null;
this.eqFilters = [];
this.visualizerRAF = null;
this.hls = null;
this.isInitialized = false;
this.proxySessionId = null;
}
/**
* Initialisiert die Web Audio API.
*/
initAudioContext() {
if (this.isInitialized) return;
this.audioEl = document.getElementById('audioPlayer');
this.videoEl = document.getElementById('videoPlayer');
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Source vom Audio-Element
this.sourceNode = this.audioContext.createMediaElementSource(this.audioEl);
// Equalizer: 7-Band
const frequencies = [60, 170, 350, 1000, 3500, 10000, 16000];
this.eqFilters = frequencies.map((freq, i) => {
const filter = this.audioContext.createBiquadFilter();
filter.type = i === 0 ? 'lowshelf' : i === frequencies.length - 1 ? 'highshelf' : 'peaking';
filter.frequency.value = freq;
filter.gain.value = 0;
filter.Q.value = 1;
return filter;
});
// Analyser für Visualizer
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
// Audio-Graph: Source -> EQ Filters -> Analyser -> Destination
let lastNode = this.sourceNode;
for (const filter of this.eqFilters) {
lastNode.connect(filter);
lastNode = filter;
}
lastNode.connect(this.analyser);
this.analyser.connect(this.audioContext.destination);
this.isInitialized = true;
}
/**
* Spielt einen Stream ab.
*/
play(url, name, type, logo, streamId, token) {
this.initAudioContext();
this.stop();
if (this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
if (type === 'video') {
this.playVideo(url, token);
} else {
this.playAudio(url);
}
}
playAudio(url) {
this.audioEl.src = url;
this.audioEl.load();
this.audioEl.play().catch(err => console.error('Audio Play Fehler:', err));
}
async playVideo(url, token) {
// Zuerst prüfen, ob der Stream einen Proxy benötigt (RTMP in .m3u etc.)
try {
const checkResp = await fetch('/api/proxy/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({ url }),
});
const checkData = await checkResp.json();
if (checkData.needs_proxy) {
console.log('Stream benötigt Proxy:', checkData.reason);
await this.playViaProxy(url, token);
return;
}
} catch (err) {
console.warn('Proxy-Check fehlgeschlagen, versuche direkt:', err);
}
// Direkte Wiedergabe (HLS oder nativ)
this.playVideoDirect(url);
}
/**
* Spielt einen Video-Stream über den Backend-Proxy (RTMP → HLS).
*/
async playViaProxy(url, token) {
try {
const resp = await fetch('/api/proxy/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
body: JSON.stringify({ url }),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || 'Proxy-Start fehlgeschlagen');
}
const data = await resp.json();
this.proxySessionId = data.session_id;
// HLS-Stream über Proxy abspielen
this.playVideoDirect(data.hls_url);
} catch (err) {
console.error('Video-Proxy Fehler:', err);
// Fallback: direkte Wiedergabe versuchen
this.playVideoDirect(url);
}
}
/**
* Spielt eine Video-URL direkt ab (HLS.js oder natives Video).
*/
playVideoDirect(url) {
const isHLS = /\.m3u8?(\?|$)/i.test(url);
if (isHLS && window.Hls && Hls.isSupported()) {
this.hls = new Hls({
maxBufferLength: 10,
maxMaxBufferLength: 30,
liveSyncDurationCount: 3,
});
this.hls.loadSource(url);
this.hls.attachMedia(this.videoEl);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.videoEl.play().catch(err => console.error('Video Play Fehler:', err));
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS Fehler:', data);
if (data.fatal) {
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
// Netzwerkfehler: Retry
console.log('HLS Netzwerkfehler, versuche erneut...');
this.hls.startLoad();
} else {
this.hls.destroy();
this.hls = null;
this.videoEl.src = url;
this.videoEl.load();
this.videoEl.play().catch(err => console.error('Video Play Fehler:', err));
}
}
});
} else if (isHLS && this.videoEl.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS-Unterstützung
this.videoEl.src = url;
this.videoEl.load();
this.videoEl.play().catch(err => console.error('Video Play Fehler:', err));
} else {
this.videoEl.src = url;
this.videoEl.load();
this.videoEl.play().catch(err => console.error('Video Play Fehler:', err));
}
}
pause() {
if (this.audioEl) this.audioEl.pause();
if (this.videoEl) this.videoEl.pause();
}
resume() {
if (this.audioEl && this.audioEl.src) {
this.audioEl.play().catch(() => {});
}
if (this.videoEl && this.videoEl.src) {
this.videoEl.play().catch(() => {});
}
}
stop() {
if (this.audioEl) {
this.audioEl.pause();
this.audioEl.removeAttribute('src');
this.audioEl.load();
}
if (this.videoEl) {
this.videoEl.pause();
this.videoEl.removeAttribute('src');
this.videoEl.load();
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
// Proxy-Session beenden
if (this.proxySessionId) {
const token = localStorage.getItem('streamdock_token');
fetch('/api/proxy/' + this.proxySessionId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
}).catch(() => {});
this.proxySessionId = null;
}
this.stopVisualizer();
}
setVolume(value) {
if (this.audioEl) this.audioEl.volume = Math.max(0, Math.min(1, value));
if (this.videoEl) this.videoEl.volume = Math.max(0, Math.min(1, value));
}
/**
* Setzt den Gain eines Equalizer-Bands.
*/
setEQBand(index, gain) {
if (this.eqFilters[index]) {
this.eqFilters[index].gain.value = gain;
}
}
/**
* Startet den Audio-Visualizer.
*/
startVisualizer(canvasId) {
if (!this.analyser) return;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const bufferLength = this.analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
this.visualizerRAF = requestAnimationFrame(draw);
this.analyser.getByteFrequencyData(dataArray);
// Canvas-Größe anpassen
canvas.width = canvas.clientWidth * window.devicePixelRatio;
canvas.height = canvas.clientHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
const width = canvas.clientWidth;
const height = canvas.clientHeight;
ctx.clearRect(0, 0, width, height);
const barWidth = (width / bufferLength) * 1.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * height;
// Farbverlauf: Accent -> heller
const hue = 170 + (i / bufferLength) * 40;
const lightness = 40 + (dataArray[i] / 255) * 20;
ctx.fillStyle = `hsl(${hue}, 70%, ${lightness}%)`;
ctx.fillRect(x, height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
};
draw();
}
/**
* Stoppt den Visualizer.
*/
stopVisualizer() {
if (this.visualizerRAF) {
cancelAnimationFrame(this.visualizerRAF);
this.visualizerRAF = null;
}
}
}
// Globale Instanz
window.streamPlayer = new StreamPlayer();

587
web/templates/index.html Normal file
View File

@@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="StreamDock - Web-basierter Stream-Player und Recorder">
<meta name="theme-color" content="#1a1a2e">
<!-- PWA -->
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
<link rel="apple-touch-icon" href="/static/img/icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>StreamDock</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body x-data="app()" x-init="init()">
<!-- Navigation -->
<nav class="navbar" x-show="isAuthenticated">
<div class="navbar-brand">
<img src="/static/img/favicon.svg" alt="StreamDock" class="navbar-logo">
<span class="navbar-title">StreamDock</span>
</div>
<button class="navbar-toggle" @click="menuOpen = !menuOpen" aria-label="Menü">
<span></span><span></span><span></span>
</button>
<div class="navbar-menu" :class="{ 'is-active': menuOpen }">
<a href="#" @click.prevent="navigate('dashboard')" :class="{ active: currentView === 'dashboard' }">Dashboard</a>
<a href="#" @click.prevent="navigate('streams')" :class="{ active: currentView === 'streams' }">Streams</a>
<a href="#" @click.prevent="navigate('radio')" :class="{ active: currentView === 'radio' }">Radio-Browser</a>
<a href="#" @click.prevent="navigate('recordings')" :class="{ active: currentView === 'recordings' }">Aufnahmen</a>
<a href="#" @click.prevent="navigate('schedules')" :class="{ active: currentView === 'schedules' }">Zeitplan</a>
<a href="#" @click.prevent="navigate('library')" :class="{ active: currentView === 'library' }">Bibliothek</a>
<a href="#" @click.prevent="navigate('settings')" :class="{ active: currentView === 'settings' }">Einstellungen</a>
<template x-if="user && user.role === 'admin'">
<a href="#" @click.prevent="navigate('admin')" :class="{ active: currentView === 'admin' }">Admin</a>
</template>
</div>
<div class="navbar-user">
<img :src="user?.avatar_path || '/static/img/default-avatar.svg'" alt="Avatar" class="avatar-small">
<span x-text="user?.username"></span>
<button @click="logout()" class="btn btn-sm btn-ghost">Logout</button>
</div>
</nav>
<!-- Login -->
<div class="login-container" x-show="!isAuthenticated">
<div class="login-card">
<img src="/static/img/favicon.svg" alt="StreamDock" class="login-logo">
<h1>StreamDock</h1>
<form @submit.prevent="login()">
<div class="form-group">
<label for="username">Benutzername</label>
<input type="text" id="username" x-model="loginForm.username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input type="password" id="password" x-model="loginForm.password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-block" :disabled="loginLoading">
<span x-show="!loginLoading">Anmelden</span>
<span x-show="loginLoading">Wird geladen...</span>
</button>
<p class="error-message" x-show="loginError" x-text="loginError"></p>
</form>
</div>
</div>
<!-- Hauptinhalt -->
<main class="main-content" x-show="isAuthenticated">
<!-- Dashboard -->
<div x-show="currentView === 'dashboard'" class="view">
<h2>Dashboard</h2>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" x-text="stats.streams">0</div>
<div class="stat-label">Streams</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="stats.recordings">0</div>
<div class="stat-label">Aufnahmen</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="stats.activeRecordings">0</div>
<div class="stat-label">Aktive Aufnahmen</div>
</div>
<div class="stat-card">
<div class="stat-value" x-text="formatBytes(stats.storageUsed)">0 B</div>
<div class="stat-label">Speicher belegt</div>
</div>
</div>
</div>
<!-- Streams (Platzhalter für weitere Views) -->
<div x-show="currentView === 'streams'" class="view">
<div class="view-header">
<h2>Meine Streams</h2>
<button class="btn btn-primary" @click="showAddStream = true">+ Stream hinzufügen</button>
</div>
<div class="stream-list" x-show="streams.length > 0">
<template x-for="stream in streams" :key="stream.id">
<div class="stream-card">
<div class="stream-info">
<img :src="stream.logo_url || '/static/img/default-stream.svg'" class="stream-logo" alt="">
<div>
<h3 x-text="stream.name"></h3>
<span class="badge" x-text="stream.stream_type"></span>
<span class="badge badge-secondary" x-text="stream.genre" x-show="stream.genre"></span>
</div>
</div>
<div class="stream-actions">
<button class="btn btn-sm btn-primary" @click="playStream(stream)">▶ Abspielen</button>
<button class="btn btn-sm btn-warning" @click="recordStream(stream)">⏺ Aufnahme</button>
<button class="btn btn-sm btn-ghost" @click="editStream(stream)">✏️</button>
<button class="btn btn-sm btn-danger" @click="deleteStream(stream)">🗑</button>
</div>
</div>
</template>
</div>
<div class="empty-state" x-show="streams.length === 0">
<p>Noch keine Streams hinzugefügt. Starte mit dem Radio-Browser oder füge manuell einen Stream hinzu.</p>
</div>
</div>
<!-- Stream-Modal -->
<div class="modal-overlay" x-show="showAddStream" @click.self="showAddStream = false; editingStream = null; streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' }">
<div class="modal">
<h3 x-text="editingStream ? 'Stream bearbeiten' : 'Stream hinzufügen'"></h3>
<form @submit.prevent="editingStream ? updateStream() : addStream()">
<div class="form-group">
<label>Name</label>
<input type="text" x-model="streamForm.name" placeholder="Mein Stream" required>
</div>
<div class="form-group">
<label>URL</label>
<input type="url" x-model="streamForm.url" placeholder="https://stream.example.com/live" required>
</div>
<div class="form-group">
<label>Typ</label>
<select x-model="streamForm.stream_type">
<option value="audio">Audio</option>
<option value="video">Video</option>
</select>
</div>
<div class="form-group">
<label>Genre</label>
<input type="text" x-model="streamForm.genre" placeholder="Rock, Pop, Jazz...">
</div>
<div class="form-group">
<label>Beschreibung</label>
<input type="text" x-model="streamForm.description" placeholder="Optionale Beschreibung">
</div>
<div class="form-group">
<label>Logo-URL</label>
<input type="url" x-model="streamForm.logo_url" placeholder="https://example.com/logo.png">
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" @click="showAddStream = false; editingStream = null; streamForm = { name: '', url: '', stream_type: 'audio', genre: '', description: '', logo_url: '' }">Abbrechen</button>
<button type="submit" class="btn btn-primary" x-text="editingStream ? 'Speichern' : 'Hinzufügen'"></button>
</div>
</form>
</div>
</div>
<!-- Radio-Browser -->
<div x-show="currentView === 'radio'" class="view">
<h2>Radio-Browser</h2>
<div class="search-bar">
<input type="text" placeholder="Sender suchen..." x-model="radioSearch" @keyup.enter="searchRadio()">
<select x-model="radioCountry">
<option value="">Alle Länder</option>
<option value="Germany">Deutschland</option>
<option value="Austria">Österreich</option>
<option value="Switzerland">Schweiz</option>
<option value="United Kingdom">UK</option>
<option value="United States">USA</option>
</select>
<button class="btn btn-primary" @click="searchRadio()">Suchen</button>
</div>
<div class="stream-list">
<template x-for="station in radioStations" :key="station.stationuuid">
<div class="stream-card">
<div class="stream-info">
<img :src="station.favicon || '/static/img/default-stream.svg'" class="stream-logo" alt="" @error="$el.src='/static/img/default-stream.svg'">
<div>
<h3 x-text="station.name"></h3>
<small x-text="station.country + ' · ' + station.codec + ' · ' + station.bitrate + ' kbps'"></small>
</div>
</div>
<div class="stream-actions">
<button class="btn btn-sm btn-primary" @click="playRadioStation(station)"></button>
<button class="btn btn-sm btn-success" @click="addRadioStation(station)">+ Hinzufügen</button>
</div>
</div>
</template>
</div>
</div>
<!-- Aufnahmen -->
<div x-show="currentView === 'recordings'" class="view">
<h2>Aufnahmen</h2>
<div class="recording-list">
<template x-for="rec in recordings" :key="rec.id">
<div class="recording-card">
<div class="recording-info">
<h3 x-text="rec.stream_name"></h3>
<small x-text="formatDate(rec.started_at) + ' · ' + formatBytes(rec.file_size)"></small>
<span class="badge" :class="'badge-' + rec.status" x-text="rec.status"></span>
</div>
<div class="recording-actions">
<button class="btn btn-sm btn-primary" @click="playRecording(rec)" x-show="rec.status === 'completed'"></button>
<button class="btn btn-sm btn-ghost" @click="downloadRecording(rec)" x-show="rec.status === 'completed'"></button>
<button class="btn btn-sm btn-ghost" @click="shareRecording(rec)" x-show="rec.status === 'completed'">🔗</button>
<button class="btn btn-sm btn-ghost" @click="sendToPlik(rec)" x-show="rec.status === 'completed'">📤 Plik</button>
<button class="btn btn-sm btn-danger" @click="stopRecording(rec)" x-show="rec.status === 'recording'">⏹ Stop</button>
<button class="btn btn-sm btn-danger" @click="deleteRecording(rec)">🗑</button>
</div>
</div>
</template>
</div>
</div>
<!-- Zeitplan -->
<div x-show="currentView === 'schedules'" class="view">
<div class="view-header">
<h2>Zeitplan</h2>
<button class="btn btn-primary" @click="showAddSchedule = true">+ Zeitplan erstellen</button>
</div>
<div class="schedule-list" x-show="schedules.length > 0">
<template x-for="sched in schedules" :key="sched.id">
<div class="schedule-card">
<div class="schedule-info">
<h3 x-text="getStreamName(sched.stream_id)"></h3>
<div class="schedule-meta">
<span x-show="sched.is_recurring">🔄 Cron: <code x-text="sched.cron_expr"></code></span>
<span x-show="!sched.is_recurring">📅 <span x-text="formatDate(sched.start_time)"></span></span>
<span><span x-text="Math.round(sched.duration / 60)"></span> Min.</span>
<span class="badge" :class="sched.is_active ? 'badge-success' : 'badge-secondary'" x-text="sched.is_active ? 'Aktiv' : 'Inaktiv'"></span>
</div>
</div>
<div class="schedule-actions">
<button class="btn btn-sm btn-ghost" @click="toggleSchedule(sched)">
<span x-text="sched.is_active ? '⏸ Pause' : '▶ Aktivieren'"></span>
</button>
<button class="btn btn-sm btn-danger" @click="deleteSchedule(sched)">🗑</button>
</div>
</div>
</template>
</div>
<div class="empty-state" x-show="schedules.length === 0">
<p>Keine geplanten Aufnahmen. Erstelle einen Zeitplan, um automatisch aufzunehmen.</p>
</div>
</div>
<!-- Zeitplan-Modal -->
<div class="modal-overlay" x-show="showAddSchedule" @click.self="showAddSchedule = false">
<div class="modal">
<h3>Neue geplante Aufnahme</h3>
<form @submit.prevent="createSchedule()">
<div class="form-group">
<label>Stream</label>
<select x-model="scheduleForm.stream_id" required>
<option value="">— Stream wählen —</option>
<template x-for="s in streams" :key="s.id">
<option :value="s.id" x-text="s.name"></option>
</template>
</select>
</div>
<div class="form-group">
<label>Dauer (Minuten)</label>
<input type="number" x-model.number="scheduleForm.duration_min" min="1" max="480" required>
</div>
<div class="form-group">
<label><input type="checkbox" x-model="scheduleForm.is_recurring"> Wiederkehrend (Cron)</label>
</div>
<div class="form-group" x-show="scheduleForm.is_recurring">
<label>Cron-Ausdruck (Sek Min Std Tag Mon Wtag)</label>
<input type="text" x-model="scheduleForm.cron_expr" placeholder="0 0 20 * * 1-5">
<small>Beispiel: <code>0 0 20 * * 1-5</code> = Mo-Fr um 20:00</small>
</div>
<div class="form-group" x-show="!scheduleForm.is_recurring">
<label>Startzeit</label>
<input type="datetime-local" x-model="scheduleForm.start_time">
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" @click="showAddSchedule = false">Abbrechen</button>
<button type="submit" class="btn btn-primary">Erstellen</button>
</div>
</form>
</div>
</div>
<!-- Bibliothek -->
<div x-show="currentView === 'library'" class="view">
<h2>Bibliothek</h2>
<div class="search-bar">
<input type="text" placeholder="In Bibliothek suchen..." x-model="librarySearch" @keyup.enter="searchLibrary()">
<button class="btn btn-primary" @click="searchLibrary()">Suchen</button>
</div>
<div x-show="libraryResults">
<h3 x-show="libraryResults?.streams?.length > 0">Streams</h3>
<div class="stream-list">
<template x-for="s in (libraryResults?.streams || [])" :key="'ls-' + s.id">
<div class="stream-card compact">
<span x-text="s.name"></span>
<button class="btn btn-sm btn-primary" @click="playStream(s)"></button>
</div>
</template>
</div>
<h3 x-show="libraryResults?.recordings?.length > 0">Aufnahmen</h3>
<div class="recording-list">
<template x-for="r in (libraryResults?.recordings || [])" :key="'lr-' + r.id">
<div class="recording-card compact">
<span x-text="r.stream_name + ' · ' + formatDate(r.started_at)"></span>
<button class="btn btn-sm btn-primary" @click="playRecording(r)"></button>
</div>
</template>
</div>
</div>
</div>
<!-- Einstellungen -->
<div x-show="currentView === 'settings'" class="view">
<h2>Einstellungen</h2>
<!-- Profil -->
<div class="settings-section">
<h3>Profil</h3>
<form @submit.prevent="updateProfile()">
<div class="form-row">
<div class="form-group">
<label>Benutzername</label>
<input type="text" x-model="profileForm.username">
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" x-model="profileForm.email">
</div>
</div>
<button type="submit" class="btn btn-primary">Profil aktualisieren</button>
</form>
<form @submit.prevent="uploadAvatar()" enctype="multipart/form-data" class="mt-1">
<div class="form-group">
<label>Profilbild</label>
<input type="file" accept="image/*" @change="avatarFile = $event.target.files[0]">
</div>
<button type="submit" class="btn btn-sm btn-ghost" :disabled="!avatarFile">Bild hochladen</button>
</form>
</div>
<!-- Passwort -->
<div class="settings-section">
<h3>Passwort ändern</h3>
<form @submit.prevent="changePassword()">
<div class="form-row">
<div class="form-group">
<label>Altes Passwort</label>
<input type="password" x-model="passwordForm.old_password" autocomplete="current-password">
</div>
<div class="form-group">
<label>Neues Passwort</label>
<input type="password" x-model="passwordForm.new_password" minlength="6" autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn btn-primary">Passwort ändern</button>
</form>
</div>
<!-- Last.fm -->
<div class="settings-section">
<h3>Last.fm Scrobbling</h3>
<div x-show="userSettings.lastfm_username">
<p>Verbunden als: <strong x-text="userSettings.lastfm_username"></strong></p>
<button class="btn btn-sm btn-danger" @click="disconnectLastFM()">Trennen</button>
</div>
<div x-show="!userSettings.lastfm_username">
<p>Nicht verbunden. Tracks werden nach 10 Sekunden Wiedergabe gescrobbelt.</p>
<button class="btn btn-primary" @click="connectLastFM()">Mit Last.fm verbinden</button>
</div>
</div>
<!-- Plik -->
<div class="settings-section">
<h3>Plik Integration</h3>
<form @submit.prevent="saveSettings()">
<div class="form-row">
<div class="form-group">
<label>Plik Server URL</label>
<input type="url" x-model="userSettings.plik_url" placeholder="https://plik.example.com">
</div>
<div class="form-group">
<label>Plik API Key</label>
<input type="password" x-model="plikApiKeyInput" placeholder="API-Key eingeben">
</div>
</div>
<button type="submit" class="btn btn-primary">Speichern</button>
</form>
</div>
<!-- Benachrichtigungen -->
<div class="settings-section">
<h3>Benachrichtigungen</h3>
<form @submit.prevent="saveSettings()">
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_on_login"> Bei Login benachrichtigen</label>
</div>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_on_rec_start"> Bei Aufnahme-Start</label>
</div>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_on_rec_end"> Bei Aufnahme-Ende</label>
</div>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_on_rec_error"> Bei Aufnahme-Fehler</label>
</div>
<h4>Kanäle</h4>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_email"> E-Mail</label>
<input type="email" x-model="userSettings.notify_email_addr" placeholder="empfaenger@example.com" x-show="userSettings.notify_email">
</div>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_webhook"> Webhook</label>
<input type="url" x-model="userSettings.notify_webhook_url" placeholder="https://example.com/webhook" x-show="userSettings.notify_webhook">
</div>
<div class="form-group">
<label><input type="checkbox" x-model="userSettings.notify_ntfy"> Ntfy</label>
<div x-show="userSettings.notify_ntfy" class="form-row">
<input type="text" x-model="userSettings.notify_ntfy_topic" placeholder="Topic">
<input type="url" x-model="userSettings.notify_ntfy_server" placeholder="Server (leer = ntfy.sh)">
</div>
</div>
<button type="submit" class="btn btn-primary">Benachrichtigungen speichern</button>
</form>
</div>
</div>
<!-- Admin -->
<div x-show="currentView === 'admin'" class="view">
<div class="view-header">
<h2>Administration</h2>
<button class="btn btn-primary" @click="showAddUser = true">+ Benutzer erstellen</button>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Speicher</th>
<th>Erstellt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<template x-for="u in adminUsers" :key="u.id">
<tr>
<td x-text="u.id"></td>
<td x-text="u.username"></td>
<td x-text="u.email"></td>
<td><span class="badge" :class="u.role === 'admin' ? 'badge-warning' : ''" x-text="u.role"></span></td>
<td x-text="formatBytes(u.storage_used) + ' / ' + (u.storage_quota > 0 ? formatBytes(u.storage_quota) : '∞')"></td>
<td x-text="formatDate(u.created_at)"></td>
<td>
<button class="btn btn-sm btn-ghost" @click="editUser(u)">✏️</button>
<button class="btn btn-sm btn-danger" @click="deleteUser(u)" x-show="u.id !== user.id">🗑</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Benutzer erstellen Modal -->
<div class="modal-overlay" x-show="showAddUser" @click.self="showAddUser = false">
<div class="modal">
<h3 x-text="editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'"></h3>
<form @submit.prevent="editingUser ? updateUser() : createUser()">
<div class="form-group">
<label>Benutzername</label>
<input type="text" x-model="userForm.username" required>
</div>
<div class="form-group">
<label>E-Mail</label>
<input type="email" x-model="userForm.email" required>
</div>
<div class="form-group">
<label>Passwort <span x-show="editingUser">(leer = unverändert)</span></label>
<input type="password" x-model="userForm.password" :required="!editingUser" autocomplete="new-password">
</div>
<div class="form-group">
<label>Rolle</label>
<select x-model="userForm.role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="form-group">
<label>Speicher-Kontingent (MB, 0 = unbegrenzt)</label>
<input type="number" x-model.number="userForm.storage_quota_mb" min="0">
</div>
<div class="form-actions">
<button type="button" class="btn btn-ghost" @click="showAddUser = false; editingUser = null">Abbrechen</button>
<button type="submit" class="btn btn-primary" x-text="editingUser ? 'Speichern' : 'Erstellen'"></button>
</div>
</form>
</div>
</div>
</main>
<!-- Player-Leiste (fixiert am unteren Rand) -->
<div class="player-bar" x-show="isAuthenticated && player.active" @wheel.prevent="handlePlayerWheel($event)">
<div class="player-info">
<img :src="player.logo || '/static/img/default-stream.svg'" class="player-logo" alt="">
<div class="player-text">
<div class="player-title" x-text="player.title || player.name"></div>
<div class="player-artist" x-text="player.artist || ''"></div>
<div class="player-no-metadata" x-show="player.playing && _streamHasMetadata === false" style="font-size: 0.7rem; color: #f59e0b; margin-top: 2px;">⚠ Kein Titelupdate verfügbar</div>
</div>
</div>
<div class="player-controls">
<button class="btn btn-icon" @click="playerToggle()">
<span x-text="player.playing ? '⏸' : '▶'"></span>
</button>
<button class="btn btn-icon" @click="playerStop()"></button>
<span class="listen-timer" x-text="player.listenTime" title="Hörzeit"></span>
<input type="range" min="0" max="100" x-model="player.volume" @input="setVolume()" class="volume-slider">
<span class="volume-label" x-text="player.volume + '%'"></span>
</div>
<div class="player-extras">
<button class="btn btn-sm btn-ghost" @click="toggleEqualizer()" :class="{ active: showEqualizer }">EQ</button>
<button class="btn btn-sm btn-ghost" @click="toggleVisualizer()" :class="{ active: showVisualizer }">VIS</button>
</div>
</div>
<!-- Equalizer Panel -->
<div class="equalizer-panel" x-show="showEqualizer" x-transition>
<h3>Equalizer</h3>
<div class="eq-sliders">
<template x-for="(band, index) in eqBands" :key="index">
<div class="eq-band">
<input type="range" min="-12" max="12" step="0.5" x-model="band.gain" @input="updateEQ(index)" class="eq-slider" orient="vertical">
<label x-text="band.label"></label>
</div>
</template>
</div>
<div class="eq-presets">
<button class="btn btn-sm" @click="eqPreset('flat')">Flat</button>
<button class="btn btn-sm" @click="eqPreset('bass')">Bass</button>
<button class="btn btn-sm" @click="eqPreset('rock')">Rock</button>
<button class="btn btn-sm" @click="eqPreset('vocal')">Vocal</button>
</div>
</div>
<!-- Audio Visualizer -->
<div class="visualizer-container" x-show="showVisualizer" x-transition>
<canvas id="visualizer" width="800" height="200"></canvas>
</div>
<!-- Audio/Video Element (versteckt) -->
<audio id="audioPlayer" crossorigin="anonymous"></audio>
<div class="video-overlay" x-show="player.type === 'video' && player.active">
<video id="videoPlayer" controls></video>
<button class="btn btn-icon video-close" @click="playerStop()"></button>
</div>
<!-- Alpine.js (CDN) -->
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
<!-- HLS.js für Video-Streams -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- App -->
<script src="/static/js/app.js"></script>
<script src="/static/js/player.js"></script>
</body>
</html>