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