Initial commit
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal 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
33
.gitignore
vendored
Normal 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
62
Dockerfile
Normal 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
58
Makefile
Normal 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."
|
||||||
39
README.md
39
README.md
@@ -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">
|
<p align="center">
|
||||||
<img src="https://assets.techniverse.net/f1/git/graphics/gray0-catonline.svg" alt="">
|
<img src="https://assets.techniverse.net/f1/git/graphics/gray0-catonline.svg" alt="">
|
||||||
|
|||||||
99
cmd/streamdock/main.go
Normal file
99
cmd/streamdock/main.go
Normal 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
37
docker-compose.yml
Normal 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
51
docs/README.md
Normal 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
297
docs/api.md
Normal 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
275
docs/architektur.md
Normal 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
114
docs/benutzerverwaltung.md
Normal 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
239
docs/docker.md
Normal 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
204
docs/entwicklung.md
Normal 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
160
docs/features.md
Normal 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
126
docs/installation.md
Normal 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
228
docs/integrationen.md
Normal 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
119
docs/konfiguration.md
Normal 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
135
docs/sicherheit.md
Normal 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
6
entrypoint.sh
Normal 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
26
go.mod
Normal 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
67
go.sum
Normal 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
1422
internal/api/handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
134
internal/api/router.go
Normal file
134
internal/api/router.go
Normal 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
91
internal/auth/auth.go
Normal 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
106
internal/auth/auth_test.go
Normal 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
91
internal/config/config.go
Normal 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
190
internal/db/db.go
Normal 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
345
internal/db/db_test.go
Normal 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
449
internal/db/queries.go
Normal 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
160
internal/lastfm/lastfm.go
Normal 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())))
|
||||||
|
}
|
||||||
85
internal/middleware/middleware.go
Normal file
85
internal/middleware/middleware.go
Normal 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
134
internal/models/models.go
Normal 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"`
|
||||||
|
}
|
||||||
141
internal/notifications/notifications.go
Normal file
141
internal/notifications/notifications.go
Normal 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
122
internal/plik/plik.go
Normal 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
|
||||||
|
}
|
||||||
124
internal/radiobrowser/radiobrowser.go
Normal file
124
internal/radiobrowser/radiobrowser.go
Normal 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
|
||||||
|
}
|
||||||
240
internal/recorder/recorder.go
Normal file
240
internal/recorder/recorder.go
Normal 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
|
||||||
|
}
|
||||||
114
internal/recorder/recorder_test.go
Normal file
114
internal/recorder/recorder_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/scheduler/scheduler.go
Normal file
86
internal/scheduler/scheduler.go
Normal 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
239
internal/stream/parser.go
Normal 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, ""
|
||||||
|
}
|
||||||
350
internal/videoproxy/videoproxy.go
Normal file
350
internal/videoproxy/videoproxy.go
Normal 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
97
tools/genicon.go
Normal 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
30
web/pwa/manifest.json
Normal 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
62
web/pwa/sw.js
Normal 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
965
web/static/css/style.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
web/static/img/default-avatar.svg
Normal file
5
web/static/img/default-avatar.svg
Normal 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 |
5
web/static/img/default-stream.svg
Normal file
5
web/static/img/default-stream.svg
Normal 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 |
14
web/static/img/favicon.svg
Normal file
14
web/static/img/favicon.svg
Normal 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
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
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
835
web/static/js/app.js
Normal 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
300
web/static/js/player.js
Normal 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
587
web/templates/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user