This commit is contained in:
Linrador 2025-12-26 01:25:04 +01:00
parent 567ac96bad
commit 05c9d04db9
29 changed files with 3515 additions and 526 deletions

View File

@ -0,0 +1,263 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Chaturbate Affiliates API (Online Rooms)
// https://chaturbate.com/affiliates/api/onlinerooms/?format=json&wm=827SM
const chaturbateOnlineRoomsURL = "https://chaturbate.com/affiliates/api/onlinerooms/?format=json&wm=827SM"
// ChaturbateRoom bildet die Felder ab, die die Online-Rooms API liefert.
// (Du kannst das später problemlos erweitern, wenn du weitere Felder brauchst.)
type ChaturbateRoom struct {
Gender string `json:"gender"`
Location string `json:"location"`
CurrentShow string `json:"current_show"` // public / private / hidden / away
Username string `json:"username"`
RoomSubject string `json:"room_subject"`
Tags []string `json:"tags"`
IsNew bool `json:"is_new"`
NumUsers int `json:"num_users"`
NumFollowers int `json:"num_followers"`
Country string `json:"country"`
SpokenLanguages string `json:"spoken_languages"`
DisplayName string `json:"display_name"`
Birthday string `json:"birthday"`
IsHD bool `json:"is_hd"`
Age int `json:"age"`
SecondsOnline int `json:"seconds_online"`
ImageURL string `json:"image_url"`
ImageURL360 string `json:"image_url_360x270"`
ChatRoomURL string `json:"chat_room_url"`
ChatRoomURLRS string `json:"chat_room_url_revshare"`
IframeEmbed string `json:"iframe_embed"`
IframeEmbedRS string `json:"iframe_embed_revshare"`
BlockCountries string `json:"block_from_countries"`
BlockStates string `json:"block_from_states"`
Recorded string `json:"recorded"` // kommt in der API als String "true"/"false"
Slug string `json:"slug"`
}
type chaturbateCache struct {
Rooms []ChaturbateRoom
FetchedAt time.Time
LastErr string
}
var (
cbHTTP = &http.Client{Timeout: 12 * time.Second}
cbMu sync.RWMutex
cb chaturbateCache
)
func fetchChaturbateOnlineRooms(ctx context.Context) ([]ChaturbateRoom, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chaturbateOnlineRoomsURL, nil)
if err != nil {
return nil, err
}
// ein "normaler" UA reduziert manchmal Block/Rate-Limit Probleme
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Accept", "application/json")
resp, err := cbHTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<10))
return nil, fmt.Errorf("chaturbate online rooms: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var rooms []ChaturbateRoom
if err := json.Unmarshal(data, &rooms); err != nil {
return nil, err
}
return rooms, nil
}
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller() {
const interval = 5 * time.Second
// nur loggen, wenn sich etwas ändert (sonst spammt es alle 5s)
lastLoggedCount := -1
lastLoggedErr := ""
// sofort ein initialer Tick
first := time.NewTimer(0)
defer first.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-first.C:
case <-ticker.C:
}
if !getSettings().UseChaturbateAPI {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
rooms, err := fetchChaturbateOnlineRooms(ctx)
cancel()
cbMu.Lock()
if err != nil {
// ❗WICHTIG: bei Fehler NICHT fetchedAt aktualisieren,
// sonst wirkt der Cache "frisch", obwohl rooms alt sind.
cb.LastErr = err.Error()
// ❗Damit offline Models nicht hängen bleiben: rooms leeren
cb.Rooms = nil
cbMu.Unlock()
if cb.LastErr != lastLoggedErr {
fmt.Println("❌ [chaturbate] online rooms fetch failed:", cb.LastErr)
lastLoggedErr = cb.LastErr
}
continue
}
// ✅ Erfolg: komplette Liste ersetzen + fetchedAt setzen
cb.LastErr = ""
cb.Rooms = rooms
cb.FetchedAt = time.Now()
cbMu.Unlock()
cb.LastErr = ""
cb.Rooms = rooms
cbMu.Unlock()
// success logging only on changes
if lastLoggedErr != "" {
fmt.Println("✅ [chaturbate] online rooms fetch recovered")
lastLoggedErr = ""
}
if len(rooms) != lastLoggedCount {
fmt.Println("✅ [chaturbate] online rooms:", len(rooms))
lastLoggedCount = len(rooms)
}
}
}
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
enabled := getSettings().UseChaturbateAPI
if !enabled {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"enabled": false,
"fetchedAt": time.Time{},
"count": 0,
"lastError": "",
"rooms": []ChaturbateRoom{},
})
return
}
// optional: ?refresh=1 triggert einen direkten Fetch (falls aktiviert)
q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
wantRefresh := q == "1" || q == "true" || q == "yes"
// Snapshot des Caches
cbMu.RLock()
rooms := cb.Rooms
fetchedAt := cb.FetchedAt
lastErr := cb.LastErr
cbMu.RUnlock()
// Wenn aktiviert aber Cache noch nie gefüllt wurde, einmalig automatisch fetchen.
// (Das verhindert das "count=0 / fetchedAt=0001" Verhalten direkt nach Neustart.)
const staleAfter = 20 * time.Second
isStale := fetchedAt.IsZero() || time.Since(fetchedAt) > staleAfter
if enabled && (wantRefresh || isStale) {
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
freshRooms, err := fetchChaturbateOnlineRooms(ctx)
cancel()
cbMu.Lock()
if err != nil {
cb.LastErr = err.Error()
// ❗WICHTIG: keine alten rooms weitergeben
cb.Rooms = nil
// ❗FetchedAt NICHT aktualisieren (bleibt letzte erfolgreiche Zeit)
} else {
cb.LastErr = ""
cb.Rooms = freshRooms
cb.FetchedAt = time.Now()
}
rooms = cb.Rooms
fetchedAt = cb.FetchedAt
lastErr = cb.LastErr
cbMu.Unlock()
}
// nil-slice vermeiden -> Frontend bekommt [] statt null
if rooms == nil {
rooms = []ChaturbateRoom{}
}
// optional: ?show=public,private,hidden,away
showFilter := strings.TrimSpace(r.URL.Query().Get("show"))
if showFilter != "" {
allowed := map[string]bool{}
for _, s := range strings.Split(showFilter, ",") {
s = strings.ToLower(strings.TrimSpace(s))
if s != "" {
allowed[s] = true
}
}
if len(allowed) > 0 {
filtered := make([]ChaturbateRoom, 0, len(rooms))
for _, rm := range rooms {
if allowed[strings.ToLower(strings.TrimSpace(rm.CurrentShow))] {
filtered = append(filtered, rm)
}
}
rooms = filtered
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
// Wir liefern ein kleines Meta-Objekt, damit du im UI sofort siehst, ob der Cache aktuell ist.
out := map[string]any{
"enabled": enabled,
"fetchedAt": fetchedAt,
"count": len(rooms),
"lastError": lastErr,
"rooms": rooms,
}
_ = json.NewEncoder(w).Encode(out)
}

86
backend/cookies_api.go Normal file
View File

@ -0,0 +1,86 @@
package main
import (
"io"
"encoding/json"
"net/http"
)
// GET /api/cookies -> {"cookies": {"name":"value",...}}
// POST /api/cookies -> accepts either {"cookies": {...}} or a plain JSON object {...}
// DELETE /api/cookies -> clears stored cookies
func cookiesHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s := getSettings()
cookies, err := decryptCookieMap(s.EncryptedCookies)
if err != nil {
http.Error(w, "could not decrypt cookies: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"cookies": cookies})
return
case http.MethodPost:
// body can be {"cookies": {...}} or just {...}
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "could not read body: "+err.Error(), http.StatusBadRequest)
return
}
type payload struct {
Cookies map[string]string `json:"cookies"`
}
var p payload
if err := json.Unmarshal(b, &p); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
}
cookies := p.Cookies
if cookies == nil {
// fallback: plain object
var m map[string]string
if err := json.Unmarshal(b, &m); err == nil {
cookies = m
}
}
if cookies == nil {
http.Error(w, "invalid json: expected {\"cookies\":{...}} or {...}", http.StatusBadRequest)
return
}
blob, err := encryptCookieMap(cookies)
if err != nil {
http.Error(w, "could not encrypt cookies: "+err.Error(), http.StatusInternalServerError)
return
}
settingsMu.Lock()
s := settings
s.EncryptedCookies = blob
settings = s
settingsMu.Unlock()
saveSettingsToDisk()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "count": len(normalizeCookieMap(cookies))})
return
case http.MethodDelete:
settingsMu.Lock()
s := settings
s.EncryptedCookies = ""
settings = s
settingsMu.Unlock()
saveSettingsToDisk()
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "Nur GET/POST/DELETE erlaubt", http.StatusMethodNotAllowed)
return
}
}

149
backend/cookies_crypto.go Normal file
View File

@ -0,0 +1,149 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// cookieKeyPath returns the filesystem path where the local encryption key is stored.
// The key is intentionally NOT stored in recorder_settings.json.
func cookieKeyPath() (string, error) {
// Prefer ./data (you already use it for models_store.db)
p, err := resolvePathRelativeToApp("data/cookies.key")
if err == nil && strings.TrimSpace(p) != "" {
_ = os.MkdirAll(filepath.Dir(p), 0o755)
return p, nil
}
// Fallback: next to the executable
p2, err2 := resolvePathRelativeToApp("cookies.key")
if err2 != nil {
return "", err2
}
_ = os.MkdirAll(filepath.Dir(p2), 0o755)
return p2, nil
}
func loadOrCreateCookieKey() ([]byte, error) {
p, err := cookieKeyPath()
if err != nil {
return nil, err
}
if b, err := os.ReadFile(p); err == nil {
// accept exactly 32 bytes, or trim if longer
if len(b) >= 32 {
return b[:32], nil
}
}
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
// 0600 is best-effort; on Windows this is still fine.
if err := os.WriteFile(p, key, 0o600); err != nil {
return nil, err
}
return key, nil
}
func normalizeCookieMap(in map[string]string) map[string]string {
out := map[string]string{}
for k, v := range in {
kk := strings.ToLower(strings.TrimSpace(k))
vv := strings.TrimSpace(v)
if kk == "" || vv == "" {
continue
}
out[kk] = vv
}
return out
}
func encryptCookieMap(cookies map[string]string) (string, error) {
clean := normalizeCookieMap(cookies)
plain, err := json.Marshal(clean)
if err != nil {
return "", err
}
return encryptBytesToBase64(plain)
}
func decryptCookieMap(blob string) (map[string]string, error) {
blob = strings.TrimSpace(blob)
if blob == "" {
return map[string]string{}, nil
}
plain, err := decryptBase64ToBytes(blob)
if err != nil {
return nil, err
}
var out map[string]string
if err := json.Unmarshal(plain, &out); err != nil {
return nil, err
}
return normalizeCookieMap(out), nil
}
func encryptBytesToBase64(plain []byte) (string, error) {
key, err := loadOrCreateCookieKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, nonce, plain, nil)
buf := append(nonce, ciphertext...)
return base64.StdEncoding.EncodeToString(buf), nil
}
func decryptBase64ToBytes(blob string) ([]byte, error) {
key, err := loadOrCreateCookieKey()
if err != nil {
return nil, err
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
buf, err := base64.StdEncoding.DecodeString(blob)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(buf) < ns {
return nil, fmt.Errorf("encrypted cookies: invalid length")
}
nonce := buf[:ns]
ciphertext := buf[ns:]
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plain, nil
}

2
backend/data/cookies.key Normal file
View File

@ -0,0 +1,2 @@
Y$ÓO¦þaß{æÏxkPTÀ
ÕiPGkkØU

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -51,6 +51,12 @@ type RecordJob struct {
PreviewImage string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
previewMu sync.Mutex `json:"-"`
previewJpeg []byte `json:"-"`
previewJpegAt time.Time `json:"-"`
previewGen bool `json:"-"`
cancel context.CancelFunc `json:"-"`
}
@ -71,6 +77,11 @@ type RecorderSettings struct {
AutoAddToDownloadList bool `json:"autoAddToDownloadList,omitempty"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies,omitempty"`
}
var (
@ -82,6 +93,9 @@ var (
AutoAddToDownloadList: false,
AutoStartAddedDownloads: false,
UseChaturbateAPI: false,
EncryptedCookies: "",
}
settingsFile = "recorder_settings.json"
)
@ -209,6 +223,9 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
return
}
current := getSettings()
in.EncryptedCookies = current.EncryptedCookies
settingsMu.Lock()
settings = in
settingsMu.Unlock()
@ -523,48 +540,69 @@ func recordPreview(w http.ResponseWriter, r *http.Request) {
jobsMu.Unlock()
if ok {
// 1⃣ Bevorzugt: aktuelles Bild aus HLS-Preview-Segmenten
previewDir := strings.TrimSpace(job.PreviewDir)
if previewDir != "" {
if img, err := extractLastFrameFromPreviewDir(previewDir); err == nil {
// dynamischer Snapshot Frontend hängt ?v=... dran,
// damit der Browser ihn neu lädt
servePreviewJPEGBytes(w, img)
return
}
// ✅ Thumbnail-Caching: nicht pro HTTP-Request ffmpeg starten.
job.previewMu.Lock()
cached := job.previewJpeg
cachedAt := job.previewJpegAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 10*time.Second
// Wenn nicht frisch, ggf. im Hintergrund aktualisieren (einmal gleichzeitig)
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob, jobID string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
// 1) aus Preview-Segmenten
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDir(previewDir)
}
// 2) Fallback: aus der Ausgabedatei
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameJPEG(outPath)
if genErr != nil {
img, _ = extractFirstFrameJPEG(outPath)
}
}
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewJpeg = img
j.previewJpegAt = time.Now()
j.previewMu.Unlock()
}
}(job, id)
}
// 2⃣ Fallback: direkt aus der Ausgabedatei (TS/MP4), z.B. wenn Preview noch nicht läuft
outPath := strings.TrimSpace(job.Output)
if outPath == "" {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
// Wir liefern entweder ein frisches Bild, oder das zuletzt gecachte.
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
servePreviewJPEGBytes(w, out)
return
}
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
fi, err := os.Stat(outPath)
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
img, err := extractLastFrameJPEG(outPath)
if err != nil {
img2, err2 := extractFirstFrameJPEG(outPath)
if err2 != nil {
http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError)
return
}
img = img2
}
servePreviewJPEGBytes(w, img)
// noch kein Bild verfügbar -> 204 (Frontend zeigt Placeholder und retry)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
@ -777,24 +815,10 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
baseURL := fmt.Sprintf("/api/record/preview?id=%s&file=", url.QueryEscape(jobID))
// LOW (ohne Audio spart Bandbreite)
lowArgs := append(commonIn,
"-vf", "scale=160:-2",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
"-an",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "4",
"-hls_flags", "delete_segments+append_list+independent_segments",
"-hls_segment_filename", filepath.Join(previewDir, "seg_low_%05d.ts"),
"-hls_base_url", baseURL,
filepath.Join(previewDir, "index.m3u8"),
)
// HQ (mit Audio)
// ✅ Nur EIN Preview-Transcode pro Job (sonst wird es bei vielen Downloads sehr teuer).
// Wir nutzen das HQ-Playlist-Format (index_hq.m3u8), aber skalieren etwas kleiner.
hqArgs := append(commonIn,
"-vf", "scale=640:-2",
"-vf", "scale=480:-2",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
@ -807,16 +831,7 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie
filepath.Join(previewDir, "index_hq.m3u8"),
)
// beide Prozesse starten (einfach & robust)
go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil && ctx.Err() == nil {
fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String()))
}
}("low", lowArgs)
// Preview-Prozess starten (einfach & robust)
go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
@ -902,6 +917,10 @@ func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/record/list", recordList)
mux.HandleFunc("/api/record/video", recordVideo)
mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
modelsPath, _ := resolvePathRelativeToApp("data/models_store.db")
fmt.Println("📦 Models DB:", modelsPath)
@ -922,8 +941,8 @@ func main() {
mux := http.NewServeMux()
registerRoutes(mux)
fmt.Println("🌐 HTTP-API aktiv: http://localhost:8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil {
fmt.Println("❌ HTTP-Server Fehler:", err)
os.Exit(1)
}
@ -1086,9 +1105,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}
func recordVideo(w http.ResponseWriter, r *http.Request) {
// ✅ NEU: Wiedergabe über Dateiname (für doneDir / recordDir)
if raw := r.URL.Query().Get("file"); strings.TrimSpace(raw) != "" {
// explizit decoden (zur Sicherheit)
// ✅ Wiedergabe über Dateiname (für doneDir / recordDir)
if raw := strings.TrimSpace(r.URL.Query().Get("file")); raw != "" {
// explizit decoden (zur Sicherheit)
file, err := url.QueryUnescape(raw)
if err != nil {
http.Error(w, "ungültiger file", http.StatusBadRequest)
@ -1096,7 +1115,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
file = strings.TrimSpace(file)
// kein Pfad, keine Backslashes, kein Traversal
// kein Pfad, keine Backslashes, kein Traversal
if file == "" ||
strings.Contains(file, "/") ||
strings.Contains(file, "\\") ||
@ -1112,12 +1131,30 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
}
s := getSettings()
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
if err != nil {
http.Error(w, "recordDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
// Kandidaten: erst doneDir, dann recordDir
candidates := []string{
filepath.Join(doneAbs, file), // bevorzugt doneDir
filepath.Join(recordAbs, file), // fallback recordDir
filepath.Join(doneAbs, file),
filepath.Join(recordAbs, file),
}
// Falls UI noch ".ts" kennt, die Datei aber schon als ".mp4" existiert:
if ext == ".ts" {
mp4File := strings.TrimSuffix(file, ext) + ".mp4"
candidates = append(candidates,
filepath.Join(doneAbs, mp4File),
filepath.Join(recordAbs, mp4File),
)
}
var outPath string
@ -1128,24 +1165,40 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
break
}
}
if outPath == "" {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
if ext == ".mp4" {
w.Header().Set("Content-Type", "video/mp4")
} else {
w.Header().Set("Content-Type", "video/mp2t")
// TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
newOut, err := maybeRemuxTS(outPath)
if err != nil {
http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(newOut) == "" {
http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError)
return
}
outPath = newOut
// sicherstellen, dass wirklich eine MP4 existiert
fi, err := os.Stat(outPath)
if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" {
http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError)
return
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
return
}
// --- ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) ---
id := r.URL.Query().Get("id")
// ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert)
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
@ -1168,7 +1221,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
if !filepath.IsAbs(outPath) {
abs, err := resolvePathRelativeToApp(outPath)
if err != nil {
http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError)
http.Error(w, "pfad auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
outPath = abs
@ -1180,14 +1233,28 @@ func recordVideo(w http.ResponseWriter, r *http.Request) {
return
}
ext := strings.ToLower(filepath.Ext(outPath))
if ext == ".mp4" {
w.Header().Set("Content-Type", "video/mp4")
} else if ext == ".ts" {
w.Header().Set("Content-Type", "video/mp2t")
// TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
newOut, err := maybeRemuxTS(outPath)
if err != nil {
http.Error(w, "TS Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(newOut) == "" {
http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError)
return
}
outPath = newOut
fi, err := os.Stat(outPath)
if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" {
http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError)
return
}
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
http.ServeFile(w, r, outPath)
}
@ -1265,6 +1332,172 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(list)
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
// Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
return
}
raw := strings.TrimSpace(r.URL.Query().Get("file"))
if raw == "" {
http.Error(w, "file fehlt", http.StatusBadRequest)
return
}
// sicher decoden
file, err := url.QueryUnescape(raw)
if err != nil {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = strings.TrimSpace(file)
// kein Pfad, keine Backslashes, kein Traversal
if file == "" ||
strings.Contains(file, "/") ||
strings.Contains(file, "\\") ||
filepath.Base(file) != file {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(file))
if ext != ".mp4" && ext != ".ts" {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
target := filepath.Join(doneAbs, file)
fi, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
if err := os.Remove(target); err != nil {
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"file": file,
})
}
func recordToggleHot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST", http.StatusMethodNotAllowed)
return
}
raw := strings.TrimSpace(r.URL.Query().Get("file"))
if raw == "" {
http.Error(w, "file fehlt", http.StatusBadRequest)
return
}
file, err := url.QueryUnescape(raw)
if err != nil {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = strings.TrimSpace(file)
// kein Pfad, keine Backslashes, kein Traversal
if file == "" ||
strings.Contains(file, "/") ||
strings.Contains(file, "\\") ||
filepath.Base(file) != file {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
ext := strings.ToLower(filepath.Ext(file))
if ext != ".mp4" && ext != ".ts" {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
src := filepath.Join(doneAbs, file)
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
newFile := file
if strings.HasPrefix(file, "HOT ") {
newFile = strings.TrimPrefix(file, "HOT ")
} else {
newFile = "HOT " + file
}
dst := filepath.Join(doneAbs, newFile)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "ziel existiert bereits", http.StatusConflict)
return
} else if !os.IsNotExist(err) {
http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if err := os.Rename(src, dst); err != nil {
http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"oldFile": file,
"newFile": newFile,
})
}
func maybeRemuxTS(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {

View File

@ -1,10 +1,13 @@
package main
import (
"encoding/csv"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
@ -102,6 +105,106 @@ func parseModelFromURL(raw string) (ParsedModelDTO, error) {
}, nil
}
type importResult struct {
Processed int `json:"processed"`
Inserted int `json:"inserted"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
}
func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, error) {
cr := csv.NewReader(r)
cr.Comma = ';'
cr.FieldsPerRecord = -1
cr.TrimLeadingSpace = true
header, err := cr.Read()
if err != nil {
return importResult{}, errors.New("CSV: header fehlt")
}
idx := map[string]int{}
for i, h := range header {
idx[strings.ToLower(strings.TrimSpace(h))] = i
}
need := []string{"url", "last_stream", "tags", "watch"}
for _, k := range need {
if _, ok := idx[k]; !ok {
return importResult{}, errors.New("CSV: Spalte fehlt: " + k)
}
}
seen := map[string]bool{}
out := importResult{}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return out, errors.New("CSV: ungültige Zeile")
}
get := func(key string) string {
i := idx[key]
if i < 0 || i >= len(rec) {
return ""
}
return strings.TrimSpace(rec[i])
}
urlRaw := get("url")
if urlRaw == "" {
out.Skipped++
continue
}
dto, err := parseModelFromURL(urlRaw)
if err != nil {
out.Skipped++
continue
}
tags := get("tags")
lastStream := get("last_stream")
watchStr := get("watch")
watch := false
if watchStr != "" {
if n, err := strconv.Atoi(watchStr); err == nil {
watch = n != 0
} else {
// "true"/"false" fallback
watch = strings.EqualFold(watchStr, "true") || strings.EqualFold(watchStr, "yes")
}
}
// dedupe innerhalb der Datei (host:modelKey)
key := strings.ToLower(dto.Host) + ":" + strings.ToLower(dto.ModelKey)
if seen[key] {
continue
}
seen[key] = true
_, inserted, err := store.UpsertFromImport(dto, tags, lastStream, watch, kind)
if err != nil {
out.Skipped++
continue
}
out.Processed++
if inserted {
out.Inserted++
} else {
out.Updated++
}
}
return out, nil
}
func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
// ✅ NEU: Parse-Endpoint (nur URL erlaubt)
@ -123,7 +226,16 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusOK, dto)
})
mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) {
modelsWriteJSON(w, http.StatusOK, store.Meta())
})
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
})
mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
modelsWriteJSON(w, http.StatusOK, store.List())
})
@ -152,6 +264,39 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusOK, m)
})
mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
kind := strings.ToLower(strings.TrimSpace(r.FormValue("kind")))
if kind != "favorite" && kind != "liked" {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": `kind must be "favorite" or "liked"`})
return
}
f, _, err := r.FormFile("file")
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"})
return
}
defer f.Close()
res, err := importModelsCSV(store, f, kind)
if err != nil {
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
modelsWriteJSON(w, http.StatusOK, res)
})
mux.HandleFunc("/api/models/flags", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})

View File

@ -16,12 +16,14 @@ import (
)
type StoredModel struct {
ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"`
Watching bool `json:"watching"`
Favorite bool `json:"favorite"`
@ -33,6 +35,20 @@ type StoredModel struct {
UpdatedAt string `json:"updatedAt"`
}
type ModelsMeta struct {
Count int `json:"count"`
UpdatedAt string `json:"updatedAt"`
}
// Kleine Payload für "watched" Listen (für Autostart/Abgleich)
type WatchedModelLite struct {
ID string `json:"id"`
Input string `json:"input"`
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"`
Watching bool `json:"watching"`
}
type ParsedModelDTO struct {
Input string `json:"input"`
IsURL bool `json:"isUrl"`
@ -117,18 +133,22 @@ func (s *ModelStore) init() error {
_, _ = db.Exec(`PRAGMA journal_mode = WAL;`)
_, _ = db.Exec(`PRAGMA synchronous = NORMAL;`)
// ✅ zuerst Schema/Columns auf "db" erstellen
if err := createModelsSchema(db); err != nil {
_ = db.Close()
return err
}
if err := ensureModelsColumns(db); err != nil {
_ = db.Close()
return err
}
// ✅ erst danach in den Store übernehmen
s.db = db
// 1x Migration: wenn DB leer ist und Legacy JSON existiert
if s.legacyJSONPath != "" {
if err := s.migrateFromJSONIfEmpty(); err != nil {
// Migration-Fehler nicht hart killen, aber zurückgeben ist auch ok.
// Ich gebe zurück, damit du es direkt siehst.
return err
}
}
@ -145,6 +165,9 @@ CREATE TABLE IF NOT EXISTS models (
host TEXT,
path TEXT,
model_key TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '',
last_stream TEXT,
watching INTEGER NOT NULL DEFAULT 0,
favorite INTEGER NOT NULL DEFAULT 0,
@ -166,6 +189,39 @@ CREATE TABLE IF NOT EXISTS models (
return nil
}
func ensureModelsColumns(db *sql.DB) error {
cols := map[string]bool{}
rows, err := db.Query(`PRAGMA table_info(models);`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
cols[name] = true
}
if !cols["tags"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN tags TEXT NOT NULL DEFAULT '';`); err != nil {
return err
}
}
if !cols["last_stream"] {
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_stream TEXT;`); err != nil {
return err
}
}
return nil
}
func canonicalHost(host string) string {
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimPrefix(h, "www.")
@ -242,6 +298,7 @@ func (s *ModelStore) migrateFromJSONIfEmpty() error {
stmt, err := tx.Prepare(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
@ -322,6 +379,7 @@ func (s *ModelStore) List() []StoredModel {
rows, err := s.db.Query(`
SELECT
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
@ -336,12 +394,13 @@ ORDER BY updated_at DESC;
for rows.Next() {
var (
id, input, host, path, modelKey, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
)
if err := rows.Scan(
&id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
); err != nil {
@ -349,25 +408,91 @@ ORDER BY updated_at DESC;
}
out = append(out, StoredModel{
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Watching: watching != 0,
Tags: tags,
LastStream: lastStream,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
return out
}
func (s *ModelStore) Meta() ModelsMeta {
if err := s.ensureInit(); err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""}
}
var count int
var updatedAt string
err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(updated_at), '') FROM models;`).Scan(&count, &updatedAt)
if err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""}
}
return ModelsMeta{Count: count, UpdatedAt: updatedAt}
}
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil {
return []WatchedModelLite{}
}
hostFilter = canonicalHost(hostFilter)
var (
rows *sql.Rows
err error
)
if hostFilter == "" {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
FROM models
WHERE watching = 1
ORDER BY updated_at DESC;
`)
} else {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
FROM models
WHERE watching = 1 AND host = ?
ORDER BY updated_at DESC;
`, hostFilter)
}
if err != nil {
return []WatchedModelLite{}
}
defer rows.Close()
out := make([]WatchedModelLite, 0, 64)
for rows.Next() {
var id, input, host, modelKey string
var watching int64
if err := rows.Scan(&id, &input, &host, &modelKey, &watching); err != nil {
continue
}
out = append(out, WatchedModelLite{
ID: id,
Input: input,
Host: host,
ModelKey: modelKey,
Watching: watching != 0,
})
}
return out
}
func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
@ -401,6 +526,7 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
@ -509,22 +635,100 @@ func (s *ModelStore) Delete(id string) error {
return err
}
func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string, watch bool, kind string) (StoredModel, bool, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, false, err
}
input := strings.TrimSpace(p.Input)
if input == "" || !p.IsURL {
return StoredModel{}, false, errors.New("Nur URL erlaubt.")
}
u, err := url.Parse(input)
if err != nil || u.Scheme == "" || u.Hostname() == "" {
return StoredModel{}, false, errors.New("Ungültige URL.")
}
host := canonicalHost(p.Host)
modelKey := strings.TrimSpace(p.ModelKey)
id := canonicalID(host, modelKey)
now := time.Now().UTC().Format(time.RFC3339Nano)
// kind: "favorite" | "liked"
fav := int64(0)
var likedArg any = nil
if kind == "favorite" {
fav = int64(1)
}
if kind == "liked" {
likedArg = int64(1)
}
s.mu.Lock()
defer s.mu.Unlock()
// exists?
inserted := false
var dummy int
err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=? LIMIT 1;`, id).Scan(&dummy)
if err == sql.ErrNoRows {
inserted = true
} else if err != nil {
return StoredModel{}, false, err
}
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
input=excluded.input,
is_url=excluded.is_url,
host=excluded.host,
path=excluded.path,
model_key=excluded.model_key,
tags=excluded.tags,
last_stream=excluded.last_stream,
watching=excluded.watching,
favorite=CASE WHEN excluded.favorite=1 THEN 1 ELSE favorite END,
liked=CASE WHEN excluded.liked IS NOT NULL THEN excluded.liked ELSE liked END,
updated_at=excluded.updated_at;
`,
id, u.String(), int64(1), host, p.Path, modelKey,
tags, lastStream,
boolToInt(watch), fav, int64(0), int64(0), likedArg,
now, now,
)
if err != nil {
return StoredModel{}, false, err
}
m, err := s.getByID(id)
return m, inserted, err
}
func (s *ModelStore) getByID(id string) (StoredModel, error) {
var (
input, host, path, modelKey, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
)
err := s.db.QueryRow(`
SELECT
input,is_url,host,path,model_key,
tags, lastStream,
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
WHERE id=?;
`, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
@ -536,18 +740,20 @@ WHERE id=?;
}
return StoredModel{
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
ID: id,
Input: input,
IsURL: isURL != 0,
Host: host,
Path: path,
ModelKey: modelKey,
Tags: tags,
LastStream: lastStream,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}

Binary file not shown.

BIN
backend/nsfwapp.exe Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

14
backend/web/dist/index.html vendored Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<script type="module" crossorigin src="/assets/index-iDPthw87.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-WtXLd9dH.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

1
backend/web/dist/vite.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,11 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import './App.css'
import Button from './components/ui/Button'
import Table, { type Column } from './components/ui/Table'
import CookieModal from './components/ui/CookieModal'
import Card from './components/ui/Card'
import Tabs, { type TabItem } from './components/ui/Tabs'
import ModelPreview from './components/ui/ModelPreview'
import RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player'
@ -15,6 +13,15 @@ import ModelsTab from './components/ui/ModelsTab'
const COOKIE_STORAGE_KEY = 'record_cookies'
function normalizeCookies(obj: Record<string, string> | null | undefined): Record<string, string> {
const input = obj ?? {}
return Object.fromEntries(
Object.entries(input)
.map(([k, v]) => [k.trim().toLowerCase(), String(v ?? '').trim()] as const)
.filter(([k, v]) => k.length > 0 && v.length > 0)
)
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
@ -24,52 +31,13 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
const baseName = (p: string) => {
const n = (p || '').replaceAll('\\', '/').trim()
const parts = n.split('/')
return parts[parts.length - 1] || ''
}
const modelNameFromOutput = (output?: string) => {
const file = baseName(output || '')
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
// <model>_MM_DD_YYYY__HH-MM-SS
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
// fallback: alles bis zum letzten '_' nehmen
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}h ${m}m`
if (m > 0) return `${m}m ${s}s`
return `${s}s`
}
const runtimeOf = (j: RecordJob) => {
const start = Date.parse(String(j.startedAt || ''))
if (!Number.isFinite(start)) return '—'
const end = j.endedAt ? Date.parse(String(j.endedAt)) : Date.now() // running -> jetzt
if (!Number.isFinite(end)) return '—'
return formatDuration(end - start)
}
type RecorderSettings = {
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -78,8 +46,42 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
ffmpegPath: '',
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
useChaturbateApi: false,
}
type StoredModel = {
id: string
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
}
type ChaturbateRoom = {
username: string
current_show?: 'public' | 'private' | 'hidden' | 'away' | string
}
type ChaturbateOnlineResponse = {
enabled: boolean
fetchedAt?: string
lastError?: string
count?: number
rooms: ChaturbateRoom[]
}
type PendingWatchedRoom = {
id: string
modelKey: string
url: string
currentShow: string
}
const sleep = (ms: number) => new Promise<void>((r) => window.setTimeout(r, ms))
function extractFirstHttpUrl(text: string): string | null {
const t = (text ?? '').trim()
if (!t) return null
@ -94,13 +96,48 @@ function extractFirstHttpUrl(text: string): string | null {
return null
}
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
function replaceBasename(fullPath: string, newBase: string) {
const norm = (fullPath || '').replaceAll('\\', '/')
const parts = norm.split('/')
parts[parts.length - 1] = newBase
return parts.join('/')
}
function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name
}
// wie backend models.go
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
function modelKeyFromFilename(fileOrPath: string): string | null {
const file = stripHotPrefix(baseName(fileOrPath))
const base = file.replace(/\.[^.]+$/, '') // ext weg
const m = base.match(reModel)
if (m?.[1]?.trim()) return m[1].trim()
const i = base.lastIndexOf('_')
if (i > 0) return base.slice(0, i)
return base ? base : null
}
export default function App() {
const [sourceUrl, setSourceUrl] = useState('')
const [parsed, setParsed] = useState<ParsedModel | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const [, setParsed] = useState<ParsedModel | null>(null)
const [, setParseError] = useState<string | null>(null)
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [error, setError] = useState<string | null>(null)
const [modelsCount, setModelsCount] = useState(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const watchedModelsRef = useRef<StoredModel[]>([])
const [, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false)
const [cookies, setCookies] = useState<Record<string, string>>({})
@ -111,6 +148,12 @@ export default function App() {
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
// ✅ Watched+Online (wartend) + Autostart-Queue
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
const autoStartQueueRef = useRef<Array<{ userKey: string; url: string }>>([])
const autoStartQueuedUsersRef = useRef<Set<string>>(new Set())
const autoStartWorkerRef = useRef(false)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
@ -128,7 +171,7 @@ export default function App() {
// um identische Clipboard-Werte nicht dauernd zu triggern
const lastClipboardUrlRef = useRef<string>('')
// settings poll (damit Umschalten im Settings-Tab ohne Reload wirkt)
// ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility
useEffect(() => {
let cancelled = false
@ -141,14 +184,78 @@ export default function App() {
}
}
const onUpdated = () => void load()
const onFocus = () => void load()
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
load() // einmal initial
return () => {
cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [])
// ✅ 1) Models-Count (leicht): nur Zahl fürs Tab, nicht die komplette Liste
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' })
const c = Number(meta?.count ?? 0)
if (!cancelled && Number.isFinite(c)) setModelsCount(c)
} catch {
// ignore
}
}
load()
const t = window.setInterval(load, 3000)
const t = window.setInterval(load, document.hidden ? 60000 : 30000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [])
// ✅ 2) Watched-Chaturbate-Models (kleine Payload) nur für den Online-Abgleich/Autostart
useEffect(() => {
if (!recSettings.useChaturbateApi) {
watchedModelsRef.current = []
return
}
let cancelled = false
let inFlight = false
const load = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<StoredModel[]>('/api/models/watched?host=chaturbate.com', { cache: 'no-store' })
if (cancelled) return
watchedModelsRef.current = Array.isArray(list) ? list : []
} catch {
if (!cancelled) watchedModelsRef.current = []
} finally {
inFlight = false
}
}
load()
const t = window.setInterval(load, document.hidden ? 30000 : 10000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
const initialCookies = useMemo(
() => Object.entries(cookies).map(([name, value]) => ({ name, value })),
[cookies]
@ -162,26 +269,62 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running')
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length + pendingWatchedRooms.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneJobs.length },
{ id: 'models', label: 'Models' },
{ id: 'models', label: 'Models', count: modelsCount },
{ id: 'settings', label: 'Einstellungen' },
]
const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy])
useEffect(() => {
const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
if (raw) {
let cancelled = false
const load = async () => {
// 1) Try backend first (persisted + encrypted in recorder_settings.json)
try {
const obj = JSON.parse(raw) as Record<string, string>
const normalized = Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k.trim().toLowerCase(), String(v ?? '').trim()])
)
setCookies(normalized)
} catch {}
const res = await apiJSON<{ cookies?: Record<string, string> }>('/api/cookies', { cache: 'no-store' })
const fromBackend = normalizeCookies(res?.cookies)
if (!cancelled) setCookies(fromBackend)
// Optional migration: if backend is empty but localStorage has cookies, push them once
if (Object.keys(fromBackend).length === 0) {
const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
if (raw) {
try {
const obj = JSON.parse(raw) as Record<string, string>
const local = normalizeCookies(obj)
if (Object.keys(local).length > 0) {
if (!cancelled) setCookies(local)
await apiJSON('/api/cookies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cookies: local }),
})
}
} catch {
// ignore
}
}
}
} catch {
// 2) Fallback: localStorage
const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
if (raw) {
try {
const obj = JSON.parse(raw) as Record<string, string>
if (!cancelled) setCookies(normalizeCookies(obj))
} catch {}
}
} finally {
if (!cancelled) setCookiesLoaded(true)
}
}
load()
return () => {
cancelled = true
}
setCookiesLoaded(true)
}, [])
useEffect(() => {
@ -285,25 +428,26 @@ export default function App() {
return Boolean(cf && sess)
}
const startUrl = useCallback(async (rawUrl: string) => {
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
const url = rawUrl.trim()
if (!url) return
if (busyRef.current) return
if (!url) return false
if (busyRef.current) return false
setError(null)
const silent = Boolean(opts?.silent)
if (!silent) setError(null)
// ❌ Chaturbate ohne Cookies blockieren
const currentCookies = cookiesRef.current
if (isChaturbate(url) && !hasRequiredChaturbateCookies(currentCookies)) {
setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
return
if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
return false
}
// Duplicate-running guard
const alreadyRunning = jobsRef.current.some(
(j) => j.status === 'running' && String(j.sourceUrl || '') === url
)
if (alreadyRunning) return
if (alreadyRunning) return true
setBusy(true)
busyRef.current = true
@ -320,18 +464,330 @@ export default function App() {
})
setJobs((prev) => [created, ...prev])
// sofort in Ref, damit Duplicate-Guard greift, bevor Jobs-Polling nachzieht
jobsRef.current = [created, ...jobsRef.current]
return true
} catch (e: any) {
setError(e?.message ?? String(e))
if (!silent) setError(e?.message ?? String(e))
return false
} finally {
setBusy(false)
busyRef.current = false
}
}, []) // arbeitet über refs, daher keine deps nötig
async function resolveModelForJob(job: RecordJob): Promise<StoredModel | null> {
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
const url = extractFirstHttpUrl(urlFromJob)
// 1) Wenn URL da ist: parse + upsert => liefert ID + flags
if (url) {
const parsed = await apiJSON<any>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: url }),
})
const saved = await apiJSON<StoredModel>('/api/models/upsert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed),
})
return saved
}
// 2) Fallback: aus Dateiname modelKey ableiten und im Store suchen
const key = modelKeyFromFilename(job.output || '')
if (!key) return null
const now = Date.now()
const cached = modelsCacheRef.current
if (!cached || now - cached.ts > 30_000) {
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] }
}
const list = modelsCacheRef.current?.list ?? []
const needle = key.toLowerCase()
// wenn mehrere: nimm Favorite zuerst, dann irgendeins
const hits = list.filter(m => (m.modelKey || '').toLowerCase() === needle)
if (hits.length === 0) return null
return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[0]
}
useEffect(() => {
let cancelled = false
if (!playerJob) {
setPlayerModel(null)
return
}
;(async () => {
try {
const m = await resolveModelForJob(playerJob)
if (!cancelled) setPlayerModel(m)
} catch {
if (!cancelled) setPlayerModel(null)
}
})()
return () => { cancelled = true }
}, [playerJob])
async function onStart() {
return startUrl(sourceUrl)
}
const handlePlayerDelete = useCallback(async (job: RecordJob) => {
// running => stop (macht mp4 remux etc)
if (job.status === 'running') {
await stopJob(job.id)
setPlayerJob(null)
return
}
const file = baseName(job.output || '')
if (!file) return
await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' })
// UI sofort aktualisieren
setDoneJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
setJobs(prev => prev.filter(j => baseName(j.output || '') !== file))
setPlayerJob(null)
}, [])
const handleToggleHot = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
const newOutput = replaceBasename(job.output || '', res.newFile)
setPlayerJob(prev => (prev ? { ...prev, output: newOutput } : prev))
setDoneJobs(prev => prev.map(j => baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
setJobs(prev => prev.map(j => baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
}, [])
async function patchModelFlags(patch: any) {
return apiJSON<any>('/api/models/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
}
const handleToggleFavorite = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
if (!m) return
const next = !Boolean(m.favorite)
const updated = await patchModelFlags({ id: m.id, favorite: next })
setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const handleToggleLike = useCallback(async (job: RecordJob) => {
let m = playerModel
if (!m) {
m = await resolveModelForJob(job)
setPlayerModel(m)
}
if (!m) return
const next = !(m.liked === true)
const updated = await patchModelFlags({ id: m.id, liked: next })
setPlayerModel(updated)
window.dispatchEvent(new Event('models-changed'))
}, [playerModel])
const normUser = (s: string) => (s || '').trim().toLowerCase()
const chaturbateUserFromUrl = (u: string): string | null => {
try {
const url = new URL(u)
if (!url.hostname.toLowerCase().includes('chaturbate.com')) return null
const parts = url.pathname.split('/').filter(Boolean)
return parts[0] ? normUser(parts[0]) : null
} catch {
return null
}
}
// ✅ 1) Poll: alle watched+online Models als "wartend" anzeigen (public/private/hidden/away)
// und public-Models in eine Start-Queue legen
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setPendingWatchedRooms([])
autoStartQueueRef.current = []
autoStartQueuedUsersRef.current = new Set()
return
}
let cancelled = false
let inFlight = false
const poll = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const canAutoStart = hasRequiredChaturbateCookies(cookiesRef.current)
const modelsList = watchedModelsRef.current
const online = await apiJSON<ChaturbateOnlineResponse>('/api/chaturbate/online', { cache: 'no-store' })
if (!online?.enabled) return
// online username -> show
const showByUser = new Map<string, string>()
for (const r of online.rooms ?? []) {
showByUser.set(normUser(r.username), String(r.current_show || 'unknown').toLowerCase())
}
// running username set (damit wir nichts doppelt starten/anzeigen)
const runningUsers = new Set(
jobsRef.current
.filter((j) => j.status === 'running')
.map((j) => chaturbateUserFromUrl(String(j.sourceUrl || '')))
.filter(Boolean) as string[]
)
// watched username set
const watchedModels = (modelsList ?? []).filter(
(m) => Boolean(m?.watching) && (
String(m?.host || '').toLowerCase().includes('chaturbate.com') || isChaturbate(String(m?.input || ''))
)
)
const watchedUsers = new Set(watchedModels.map((m) => normUser(m.modelKey)).filter(Boolean))
// ✅ Queue aufräumen: raus, wenn nicht mehr watched, offline oder schon running
{
const nextQueue: Array<{ userKey: string; url: string }> = []
for (const q of autoStartQueueRef.current) {
if (!watchedUsers.has(q.userKey)) continue
if (!showByUser.has(q.userKey)) continue
if (runningUsers.has(q.userKey)) continue
nextQueue.push(q)
}
autoStartQueueRef.current = nextQueue
autoStartQueuedUsersRef.current = new Set(nextQueue.map((q) => q.userKey))
}
// ✅ Pending Map: alle watched+online, die NICHT running sind
const pendingMap = new Map<string, PendingWatchedRoom>()
for (const m of watchedModels) {
const key = normUser(m.modelKey)
if (!key) continue
const currentShow = showByUser.get(key)
if (!currentShow) continue // offline -> nicht pending
// running -> nicht pending (steht ja in Jobs)
if (runningUsers.has(key)) continue
const url = /^https?:\/\//i.test(m.input || '')
? String(m.input).trim()
: `https://chaturbate.com/${m.modelKey}/`
// ✅ erst mal ALLE watched+online als wartend anzeigen (auch public)
if (!pendingMap.has(key)) {
pendingMap.set(key, { id: m.id, modelKey: m.modelKey, url, currentShow })
}
// ✅ public in Queue (wenn Cookies da), aber ohne Duplikate
if (currentShow === 'public' && canAutoStart && !autoStartQueuedUsersRef.current.has(key)) {
autoStartQueueRef.current.push({ userKey: key, url })
autoStartQueuedUsersRef.current.add(key)
}
}
if (!cancelled) setPendingWatchedRooms([...pendingMap.values()])
} catch {
// silent
} finally {
inFlight = false
}
}
poll()
const t = window.setInterval(poll, document.hidden ? 15000 : 5000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [recSettings.useChaturbateApi])
// ✅ 2) Worker: startet Queue nacheinander (5s Pause nach jedem Start)
useEffect(() => {
if (!recSettings.useChaturbateApi) return
let cancelled = false
const loop = async () => {
if (autoStartWorkerRef.current) return
autoStartWorkerRef.current = true
try {
while (!cancelled) {
// wenn UI gerade manuell startet -> warten
if (busyRef.current) {
await sleep(500)
continue
}
const next = autoStartQueueRef.current.shift()
if (!next) {
await sleep(1000)
continue
}
// aus queued-set entfernen (damit Poll ggf. neu einreihen kann, falls Start nicht klappt)
autoStartQueuedUsersRef.current.delete(next.userKey)
// start attempt (silent)
const ok = await startUrl(next.url, { silent: true })
if (ok) {
// pending sofort rausnehmen, damit UI direkt "running" zeigt
setPendingWatchedRooms((prev) => prev.filter((p) => normUser(p.modelKey) !== next.userKey))
}
// ✅ 5s Abstand nach (erfolgreichem) Starten ich warte auch bei failure,
// damit wir nicht in eine schnelle Retry-Schleife laufen.
if (ok) {
await sleep(5000)
} else {
await sleep(5000)
}
}
} finally {
autoStartWorkerRef.current = false
}
}
void loop()
return () => {
cancelled = true
}
}, [recSettings.useChaturbateApi, startUrl])
useEffect(() => {
if (!autoAddEnabled && !autoStartEnabled) return
if (!navigator.clipboard?.readText) return
@ -459,6 +915,7 @@ export default function App() {
{selectedTab === 'running' && (
<RunningDownloads
jobs={runningJobs}
pending={pendingWatchedRooms}
onOpenPlayer={openPlayer}
onStopJob={stopJob}
/>
@ -482,12 +939,19 @@ export default function App() {
onClose={() => setCookieModalOpen(false)}
initialCookies={initialCookies}
onApply={(list) => {
const normalized = Object.fromEntries(
list
.map((c) => [c.name.trim().toLowerCase(), c.value.trim()] as const)
.filter(([k, v]) => k.length > 0 && v.length > 0)
const normalized = normalizeCookies(
Object.fromEntries(list.map((c) => [c.name, c.value] as const))
)
setCookies(normalized)
// Persist encrypted in recorder_settings.json via backend
void apiJSON('/api/cookies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cookies: normalized }),
}).catch(() => {
// keep UI usable; backend persistence failed
})
}}
/>
@ -497,6 +961,15 @@ export default function App() {
expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((v) => !v)}
onClose={() => setPlayerJob(null)}
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true}
onDelete={handlePlayerDelete}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
/>
)}
</div>

View File

@ -2,6 +2,7 @@ import * as React from 'react'
type Variant = 'primary' | 'secondary' | 'soft'
type Size = 'xs' | 'sm' | 'md' | 'lg'
type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber'
export type ButtonProps = Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
@ -10,6 +11,7 @@ export type ButtonProps = Omit<
children: React.ReactNode
variant?: Variant
size?: Size
color?: Color
rounded?: 'sm' | 'md' | 'full'
leadingIcon?: React.ReactNode
trailingIcon?: React.ReactNode
@ -37,42 +39,69 @@ const sizeMap: Record<Size, string> = {
lg: 'px-3.5 py-2.5 text-sm',
}
// Varianten basieren auf deinen Klassen :contentReference[oaicite:1]{index=1}
const variantMap: Record<Variant, string> = {
primary:
'bg-indigo-600 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
const colorMap: Record<Color, Record<Variant, string>> = {
indigo: {
primary:
'bg-indigo-600 text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
'dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
},
blue: {
primary:
'bg-blue-600 text-white shadow-xs hover:bg-blue-500 focus-visible:outline-blue-600 ' +
'dark:bg-blue-500 dark:shadow-none dark:hover:bg-blue-400 dark:focus-visible:outline-blue-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30',
},
emerald: {
primary:
'bg-emerald-600 text-white shadow-xs hover:bg-emerald-500 focus-visible:outline-emerald-600 ' +
'dark:bg-emerald-500 dark:shadow-none dark:hover:bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30',
},
red: {
primary:
'bg-red-600 text-white shadow-xs hover:bg-red-500 focus-visible:outline-red-600 ' +
'dark:bg-red-500 dark:shadow-none dark:hover:bg-red-400 dark:focus-visible:outline-red-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30',
},
amber: {
primary:
'bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:outline-amber-500 ' +
'dark:bg-amber-500 dark:shadow-none dark:hover:bg-amber-400 dark:focus-visible:outline-amber-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
soft:
'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30',
},
}
function Spinner() {
return (
<svg
viewBox="0 0 24 24"
className="size-4 animate-spin"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.25"
/>
<path
d="M22 12a10 10 0 0 1-10 10"
fill="none"
stroke="currentColor"
strokeWidth="4"
opacity="0.9"
/>
<svg viewBox="0 0 24 24" className="size-4 animate-spin" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="4" opacity="0.25" />
<path d="M22 12a10 10 0 0 1-10 10" fill="none" stroke="currentColor" strokeWidth="4" opacity="0.9" />
</svg>
)
}
@ -80,6 +109,7 @@ function Spinner() {
export default function Button({
children,
variant = 'primary',
color = 'indigo',
size = 'md',
rounded = 'md',
leadingIcon,
@ -100,7 +130,7 @@ export default function Button({
base,
roundedMap[rounded],
sizeMap[size],
variantMap[variant],
colorMap[color][variant],
iconGap,
className
)}
@ -116,9 +146,7 @@ export default function Button({
<span>{children}</span>
{trailingIcon && !isLoading && (
<span className="-mr-0.5">{trailingIcon}</span>
)}
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
</button>
)
}

View File

@ -3,12 +3,13 @@
import * as React from 'react'
import { useMemo } from 'react'
import Table, { type Column } from './Table'
import Table, { type Column, type SortState } from './Table'
import Card from './Card'
import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview'
import ContextMenu, { type ContextMenuItem } from './ContextMenu'
import { buildDownloadContextMenu } from './DownloadContextMenu'
import Button from './Button'
type Props = {
jobs: RecordJob[]
@ -59,8 +60,16 @@ const modelNameFromOutput = (output?: string) => {
}
export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) {
const PAGE_SIZE = 50
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null)
// 🗑️ lokale Optimistik: sofort ausblenden, ohne auf das nächste Polling zu warten
const [deletedKeys, setDeletedKeys] = React.useState<Set<string>>(() => new Set())
const [deletingKeys, setDeletingKeys] = React.useState<Set<string>>(() => new Set())
const [sort, setSort] = React.useState<SortState>(null)
// 🔄 globaler Tick für animierte Thumbnails der fertigen Videos
const [thumbTick, setThumbTick] = React.useState(0)
@ -85,6 +94,52 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
setCtx({ x, y, job })
}
const markDeleting = React.useCallback((key: string, value: boolean) => {
setDeletingKeys((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}, [])
const markDeleted = React.useCallback((key: string) => {
setDeletedKeys((prev) => {
const next = new Set(prev)
next.add(key)
return next
})
}, [])
const deleteVideo = React.useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const key = keyFor(job)
if (!file) {
window.alert('Kein Dateiname gefunden kann nicht löschen.')
return
}
if (deletingKeys.has(key)) return
markDeleting(key, true)
try {
const res = await fetch(`/api/record/delete?file=${encodeURIComponent(file)}`, {
method: 'POST',
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
markDeleted(key)
} catch (e: any) {
window.alert(`Löschen fehlgeschlagen: ${String(e?.message || e)}`)
} finally {
markDeleting(key, false)
}
},
[deletingKeys, markDeleted, markDeleting]
)
const items = React.useMemo<ContextMenuItem[]>(() => {
if (!ctx) return []
const j = ctx.job
@ -113,10 +168,24 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
onToggleHot: (job) => console.log('toggle hot', job.id),
onToggleKeep: (job) => console.log('toggle keep', job.id),
onDelete: (job) => console.log('delete', job.id),
onDelete: (job) => {
setCtx(null)
void deleteVideo(job)
},
},
})
}, [ctx, onOpenPlayer])
}, [ctx, deleteVideo, onOpenPlayer])
const runtimeSecondsForSort = React.useCallback((job: RecordJob) => {
const k = keyFor(job)
const sec = durations[k]
if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec
const start = Date.parse(String(job.startedAt || ''))
const end = Date.parse(String(job.endedAt || ''))
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return Number.POSITIVE_INFINITY
return (end - start) / 1000
}, [durations])
const rows = useMemo(() => {
const map = new Map<string, RecordJob>()
@ -130,13 +199,20 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
if (map.has(k)) map.set(k, { ...map.get(k)!, ...j })
}
const list = Array.from(map.values()).filter(
(j) => j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
)
const list = Array.from(map.values()).filter((j) => {
if (deletedKeys.has(keyFor(j))) return false
return j.status === 'finished' || j.status === 'failed' || j.status === 'stopped'
})
list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || '')))
return list
}, [jobs, doneJobs])
}, [jobs, doneJobs, deletedKeys])
React.useEffect(() => {
setVisibleCount(PAGE_SIZE)
}, [rows.length])
const visibleRows = React.useMemo(() => rows.slice(0, visibleCount), [rows, visibleCount])
// 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt
const runtimeOf = (job: RecordJob): string => {
@ -178,6 +254,8 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{
key: 'model',
header: 'Modelname',
sortable: true,
sortValue: (j) => modelNameFromOutput(j.output),
cell: (j) => {
const name = modelNameFromOutput(j.output)
return (
@ -190,11 +268,15 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{
key: 'output',
header: 'Datei',
sortable: true,
sortValue: (j) => baseName(j.output || ''),
cell: (j) => baseName(j.output || ''),
},
{
key: 'status',
header: 'Status',
sortable: true,
sortValue: (j) => (j.status === 'finished' ? 0 : j.status === 'stopped' ? 1 : j.status === 'failed' ? 2 : 9),
cell: (j) => {
if (j.status !== 'failed') return j.status
const code = httpCodeFromError(j.error)
@ -209,6 +291,8 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{
key: 'runtime',
header: 'Dauer',
sortable: true,
sortValue: (j) => runtimeSecondsForSort(j),
cell: (j) => runtimeOf(j),
},
{
@ -216,7 +300,26 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
header: 'Aktion',
align: 'right',
srOnlyHeader: true,
cell: () => <span className="text-xs text-gray-400"></span>,
cell: (j) => {
const k = keyFor(j)
const busy = deletingKeys.has(k)
return (
<Button
disabled={busy}
variant='soft'
color='red'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
aria-label="Video löschen"
title="Video löschen"
>
{busy ? '…' : 'Löschen'}
</Button>
)
},
},
]
@ -234,7 +337,7 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
<>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{rows.map((j) => {
{visibleRows.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
@ -272,20 +375,35 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
</div>
</div>
{/* ✅ Menü-Button für Touch/Small Devices */}
<button
type="button"
className="shrink-0 rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
<div className="shrink-0 flex items-center gap-1">
{/* 🗑️ Direkt-Löschen */}
<Button
aria-label="Video löschen"
title="Video löschen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void deleteVideo(j)
}}
>
🗑
</Button>
{/* ✅ Menü-Button für Touch/Small Devices */}
<button
type="button"
className="rounded px-2 py-1 text-lg leading-none hover:bg-black/5 dark:hover:bg-white/10"
aria-label="Aktionen"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
const r = (e.currentTarget as HTMLElement).getBoundingClientRect()
openCtxAt(j, r.left, r.bottom + 6)
}}
>
</button>
</div>
</div>
}
>
@ -331,11 +449,13 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={rows}
rows={visibleRows}
columns={columns}
getRowKey={(j) => keyFor(j)}
striped
fullWidth
sort={sort}
onSortChange={setSort}
onRowClick={onOpenPlayer}
onRowContextMenu={(job, e) => openCtx(job, e)}
/>
@ -348,6 +468,18 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop
items={items}
onClose={() => setCtx(null)}
/>
{rows.length > visibleCount ? (
<div className="mt-3 flex justify-center">
<button
type="button"
className="rounded-md bg-black/5 px-3 py-2 text-sm font-medium hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
onClick={() => setVisibleCount((v) => Math.min(rows.length, v + PAGE_SIZE))}
>
Mehr laden ({Math.min(PAGE_SIZE, rows.length - visibleCount)} von {rows.length - visibleCount})
</button>
</div>
) : null}
</>
)
}

View File

@ -19,11 +19,13 @@ export default function LiveHlsVideo({
let cancelled = false
let hls: Hls | null = null
const video = ref.current
if (!video) return
// NOTE: Narrowing ("if (!video) return") wirkt NICHT in async-Callbacks.
// Deshalb reichen wir das Element explizit in start() rein.
const videoEl = ref.current
if (!videoEl) return
setBroken(false)
video.muted = muted
videoEl.muted = muted
async function waitForManifest() {
const started = Date.now()
@ -42,7 +44,7 @@ export default function LiveHlsVideo({
return false
}
async function start() {
async function start(video: HTMLVideoElement) {
const ok = await waitForManifest()
if (!ok || cancelled) {
if (!cancelled) setBroken(true)
@ -79,7 +81,7 @@ export default function LiveHlsVideo({
})
}
start()
void start(videoEl)
return () => {
cancelled = true

View File

@ -1,21 +1,69 @@
// frontend/src/components/ui/ModelPreview.tsx
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo'
type Props = {
jobId: string
// wird von außen hochgezählt (z.B. alle 5s)
thumbTick: number
// Optional: wird von außen hochgezählt (z.B. alle 5s). Wenn nicht gesetzt,
// tickt die Komponente selbst (weniger Re-Renders im Parent).
thumbTick?: number
// wie oft (ms) der Thumbnail neu geladen werden soll, wenn thumbTick nicht gesetzt ist
autoTickMs?: number
}
export default function ModelPreview({ jobId, thumbTick }: Props) {
export default function ModelPreview({ jobId, thumbTick, autoTickMs = 30000 }: Props) {
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
useEffect(() => {
// Wenn Parent tickt, kein lokales Ticken
if (typeof thumbTick === 'number') return
// Nur animieren, wenn im Sichtbereich UND Tab sichtbar
if (!inView || document.hidden) return
const id = window.setInterval(() => {
setLocalTick((t) => t + 1)
}, autoTickMs)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView])
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry?.isIntersecting))
},
{
root: null,
threshold: 0.1,
}
)
obs.observe(el)
return () => obs.disconnect()
}, [])
const tick = typeof thumbTick === 'number' ? thumbTick : localTick
// bei neuem Tick Error-Flag zurücksetzen (damit wir retries erlauben)
useEffect(() => {
setImgError(false)
}, [tick])
// Thumbnail mit Cache-Buster (?v=...)
const thumb = useMemo(
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${thumbTick}`,
[jobId, thumbTick]
() => `/api/record/preview?id=${encodeURIComponent(jobId)}&v=${tick}`,
[jobId, tick]
)
// HLS nur für große Vorschau im Popover
@ -41,13 +89,24 @@ export default function ModelPreview({ jobId, thumbTick }: Props) {
)
}
>
<div className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden">
<img
src={thumb}
loading="lazy"
alt=""
className="w-full h-full object-cover"
/>
<div
ref={rootRef}
className="w-20 h-16 rounded bg-gray-100 dark:bg-white/5 overflow-hidden flex items-center justify-center"
>
{!imgError ? (
<img
src={thumb}
loading="lazy"
alt=""
className="w-full h-full object-cover"
onError={() => setImgError(true)}
onLoad={() => setImgError(false)}
/>
) : (
<div className="text-[10px] text-gray-500 dark:text-gray-400 px-1 text-center">
keine Vorschau
</div>
)}
</div>
</HoverPopover>
)

View File

@ -5,6 +5,9 @@ import clsx from 'clsx'
import Card from './Card'
import Button from './Button'
import Table, { type Column } from './Table'
import Modal from './Modal'
import Pagination from './Pagination'
type ParsedModel = {
input: string
@ -14,6 +17,8 @@ type ParsedModel = {
modelKey: string
}
type ImportKind = 'liked' | 'favorite'
export type StoredModel = {
id: string
input: string
@ -21,6 +26,9 @@ export type StoredModel = {
host?: string
path?: string
modelKey: string
tags?: string
lastStream?: string
watching: boolean
favorite: boolean
hot: boolean
@ -30,6 +38,7 @@ export type StoredModel = {
updatedAt: string
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
@ -68,6 +77,41 @@ function normalizeHttpUrl(raw: string): string | null {
}
}
function splitTags(raw?: string): string[] {
if (!raw) return []
const tags = raw
.split(',')
.map((t) => t.trim())
.filter(Boolean)
const uniq = Array.from(new Set(tags))
// ✅ alphabetisch (case-insensitive), aber original casing bleibt
uniq.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
return uniq
}
function TagBadge({
children,
title,
}: {
children: React.ReactNode
title?: string
}) {
return (
<span
title={title}
className="inline-flex max-w-[11rem] items-center truncate rounded-md bg-sky-50 px-2 py-0.5 text-xs text-sky-700 dark:bg-sky-500/10 dark:text-sky-200"
>
{children}
</span>
)
}
function IconToggle({
title,
active,
@ -113,11 +157,55 @@ export default function ModelsTab() {
const [err, setErr] = React.useState<string | null>(null)
const [q, setQ] = React.useState('')
const [page, setPage] = React.useState(1)
const pageSize = 10
const [input, setInput] = React.useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null)
const [adding, setAdding] = React.useState(false)
const [importOpen, setImportOpen] = React.useState(false)
const [importFile, setImportFile] = React.useState<File | null>(null)
const [importMsg, setImportMsg] = React.useState<string | null>(null)
const [importKind, setImportKind] = React.useState<ImportKind>('favorite')
const [importing, setImporting] = React.useState(false)
const [importErr, setImportErr] = React.useState<string | null>(null)
async function doImport() {
if (!importFile) return
setImporting(true)
setImportMsg(null)
setErr(null)
try {
const fd = new FormData()
fd.append('file', importFile)
fd.append('kind', importKind)
const res = await fetch('/api/models/import', { method: 'POST', body: fd })
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setImportMsg(`✅ Import: ${data.inserted} neu, ${data.updated} aktualisiert, ${data.skipped} übersprungen`)
setImportOpen(false)
setImportFile(null)
await refresh()
} catch (e: any) {
setImportErr(e?.message ?? String(e))
} finally {
setImporting(false)
}
}
const openImport = () => {
setImportErr(null)
setImportMsg(null)
setImportFile(null)
setImportKind('favorite') // optional
setImportOpen(true)
}
const refresh = React.useCallback(async () => {
setLoading(true)
setErr(null)
@ -135,6 +223,12 @@ export default function ModelsTab() {
refresh()
}, [refresh])
React.useEffect(() => {
const onChanged = () => { void refresh() }
window.addEventListener('models-changed', onChanged as any)
return () => window.removeEventListener('models-changed', onChanged as any)
}, [refresh])
// Parse (debounced) nur bei gültiger URL
React.useEffect(() => {
const raw = input.trim()
@ -176,14 +270,36 @@ export default function ModelsTab() {
return () => window.clearTimeout(t)
}, [input])
const modelsWithHay = React.useMemo(() => {
return models.map((m) => ({
m,
hay: `${m.modelKey} ${m.host ?? ''} ${m.input ?? ''} ${m.tags ?? ''}`.toLowerCase(),
}))
}, [models])
const deferredQ = React.useDeferredValue(q)
const filtered = React.useMemo(() => {
const needle = q.trim().toLowerCase()
const needle = deferredQ.trim().toLowerCase()
if (!needle) return models
return models.filter((m) => {
const hay = [m.modelKey, m.host ?? '', m.input ?? ''].join(' ').toLowerCase()
return hay.includes(needle)
})
}, [models, q])
return modelsWithHay.filter(x => x.hay.includes(needle)).map(x => x.m)
}, [models, modelsWithHay, deferredQ])
React.useEffect(() => {
setPage(1)
}, [q])
const totalItems = filtered.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
React.useEffect(() => {
if (page > totalPages) setPage(totalPages)
}, [page, totalPages])
const pageRows = React.useMemo(() => {
const start = (page - 1) * pageSize
return filtered.slice(start, start + pageSize)
}, [filtered, page, pageSize])
const upsertFromParsed = async () => {
if (!parsed) return
@ -221,7 +337,13 @@ export default function ModelsTab() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...body }),
})
setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
setModels((prev) => {
const idx = prev.findIndex((m) => m.id === updated.id)
if (idx === -1) return prev
const next = prev.slice()
next[idx] = updated
return next
})
} catch (e: any) {
setErr(e?.message ?? String(e))
}
@ -337,12 +459,31 @@ export default function ModelsTab() {
{
key: 'tags',
header: 'Tags',
cell: (m) => (
<div className="flex flex-wrap gap-2">
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
</div>
),
cell: (m) => {
const tags = splitTags(m.tags)
const shown = tags.slice(0, 6)
const rest = tags.length - shown.length
const full = tags.join(', ')
return (
<div className="flex flex-wrap gap-2" title={full || undefined}>
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
{shown.map((t) => (
<TagBadge key={t} title={t}>
{t}
</TagBadge>
))}
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
{!m.hot && !m.keep && tags.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
) : null}
</div>
)
},
},
]
}, [])
@ -350,7 +491,10 @@ export default function ModelsTab() {
return (
<div className="space-y-4">
<Card header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>} grayBody>
<Card
header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>}
grayBody
>
<div className="grid gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
@ -388,19 +532,28 @@ export default function ModelsTab() {
<Card
header={
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">Models ({filtered.length})</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="w-[220px] rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/>
<div className="text-sm font-medium text-gray-900 dark:text-white">
Models ({filtered.length})
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={openImport}>
Importieren
</Button>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen…"
className="w-[220px] rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white"
/>
</div>
</div>
}
noBodyPadding
>
<Table
rows={filtered}
rows={pageRows}
columns={columns}
getRowKey={(m) => m.id}
striped
@ -409,7 +562,105 @@ export default function ModelsTab() {
stickyHeader
onRowClick={(m) => m.input && window.open(m.input, '_blank', 'noreferrer')}
/>
<Pagination
page={page}
pageSize={pageSize}
totalItems={totalItems}
onPageChange={setPage}
/>
</Card>
<Modal
open={importOpen}
onClose={() => !importing && setImportOpen(false)}
title="Models importieren"
footer={
<>
<Button
variant="secondary"
onClick={() => setImportOpen(false)}
disabled={importing}
>
Abbrechen
</Button>
<Button
variant="primary"
onClick={doImport}
isLoading={importing}
disabled={!importFile || importing}
>
Import starten
</Button>
</>
}
>
<div className="space-y-3">
<div className="text-sm text-gray-700 dark:text-gray-300">
Wähle eine CSV-Datei zum Import aus.
</div>
<div className="space-y-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Import-Typ
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="import-kind"
value="favorite"
checked={importKind === 'favorite'}
onChange={() => setImportKind('favorite')}
disabled={importing}
/>
Favoriten ()
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="radio"
name="import-kind"
value="liked"
checked={importKind === 'liked'}
onChange={() => setImportKind('liked')}
disabled={importing}
/>
Gefällt mir ()
</label>
</div>
</div>
<input
type="file"
accept=".csv,text/csv"
onChange={(e) => {
const f = e.target.files?.[0] ?? null
setImportErr(null)
setImportFile(f)
}}
className="block w-full text-sm text-gray-700 file:mr-4 file:rounded-md file:border-0 file:bg-gray-100 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-gray-900 hover:file:bg-gray-200 dark:text-gray-200 dark:file:bg-white/10 dark:file:text-white dark:hover:file:bg-white/20"
/>
{importFile ? (
<div className="text-xs text-gray-600 dark:text-gray-400">
Ausgewählt: <span className="font-medium">{importFile.name}</span>
</div>
) : null}
{importErr ? (
<div className="text-xs text-red-600 dark:text-red-300">{importErr}</div>
) : null}
{/* OPTIONAL: Import-Ergebnis anzeigen, falls du state importMsg/importResult hast */}
{importMsg ? (
<div className="rounded-md bg-green-50 px-3 py-2 text-xs text-green-700 dark:bg-green-500/10 dark:text-green-200">
{importMsg}
</div>
) : null}
</div>
</Modal>
</div>
)
}

View File

@ -0,0 +1,262 @@
'use client'
import * as React from 'react'
import clsx from 'clsx'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
type PageItem = number | 'ellipsis'
export type PaginationProps = {
page: number // 1-based
pageSize: number
totalItems: number
onPageChange: (page: number) => void
/** wie viele Seiten links/rechts neben aktueller Seite */
siblingCount?: number
/** wie viele Seiten am Anfang/Ende immer gezeigt werden */
boundaryCount?: number
/** Summary "Showing x to y of z" anzeigen */
showSummary?: boolean
/** Wrapper Klassen */
className?: string
/** Labels (optional) */
ariaLabel?: string
prevLabel?: string
nextLabel?: string
}
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n))
}
function range(start: number, end: number): number[] {
const out: number[] = []
for (let i = start; i <= end; i++) out.push(i)
return out
}
function getPageItems(
totalPages: number,
current: number,
boundaryCount: number,
siblingCount: number
): PageItem[] {
if (totalPages <= 1) return [1]
const first = 1
const last = totalPages
const startPages = range(first, Math.min(boundaryCount, last))
const endPages = range(Math.max(last - boundaryCount + 1, boundaryCount + 1), last)
const siblingsStart = Math.max(
Math.min(
current - siblingCount,
last - boundaryCount - siblingCount * 2 - 1
),
boundaryCount + 1
)
const siblingsEnd = Math.min(
Math.max(
current + siblingCount,
boundaryCount + siblingCount * 2 + 2
),
last - boundaryCount
)
const items: PageItem[] = []
// start
items.push(...startPages)
// left gap
if (siblingsStart > boundaryCount + 1) {
items.push('ellipsis')
} else if (boundaryCount + 1 < last - boundaryCount) {
items.push(boundaryCount + 1)
}
// siblings
items.push(...range(siblingsStart, siblingsEnd))
// right gap
if (siblingsEnd < last - boundaryCount) {
items.push('ellipsis')
} else if (last - boundaryCount > boundaryCount) {
items.push(last - boundaryCount)
}
// end
items.push(...endPages)
// dedupe + keep order
const seen = new Set<string>()
return items.filter((x) => {
const k = String(x)
if (seen.has(k)) return false
seen.add(k)
return true
})
}
function PageButton({
active,
disabled,
rounded,
onClick,
children,
title,
}: {
active?: boolean
disabled?: boolean
rounded?: 'l' | 'r' | 'none'
onClick?: () => void
children: React.ReactNode
title?: string
}) {
const roundedCls =
rounded === 'l' ? 'rounded-l-md' : rounded === 'r' ? 'rounded-r-md' : ''
return (
<button
type="button"
disabled={disabled}
onClick={disabled ? undefined : onClick}
title={title}
className={clsx(
'relative inline-flex items-center px-4 py-2 text-sm font-semibold focus:z-20 focus:outline-offset-0',
roundedCls,
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
active
? 'z-10 bg-indigo-600 text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:focus-visible:outline-indigo-500'
: 'text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:text-gray-200 dark:inset-ring-gray-700 dark:hover:bg-white/5'
)}
aria-current={active ? 'page' : undefined}
>
{children}
</button>
)
}
export default function Pagination({
page,
pageSize,
totalItems,
onPageChange,
siblingCount = 1,
boundaryCount = 1,
showSummary = true,
className,
ariaLabel = 'Pagination',
prevLabel = 'Previous',
nextLabel = 'Next',
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil((totalItems || 0) / Math.max(1, pageSize || 1)))
const current = clamp(page || 1, 1, totalPages)
if (totalPages <= 1) return null
const from = totalItems === 0 ? 0 : (current - 1) * pageSize + 1
const to = Math.min(current * pageSize, totalItems)
const items = getPageItems(totalPages, current, boundaryCount, siblingCount)
const go = (p: number) => onPageChange(clamp(p, 1, totalPages))
return (
<div
className={clsx(
'flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 dark:border-white/10 dark:bg-transparent',
className
)}
>
{/* Mobile: nur Previous/Next */}
<div className="flex flex-1 justify-between sm:hidden">
<button
type="button"
onClick={() => go(current - 1)}
disabled={current <= 1}
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{prevLabel}
</button>
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-white/10 dark:bg-white/5 dark:text-gray-200 dark:hover:bg-white/10"
>
{nextLabel}
</button>
</div>
{/* Desktop: Summary + Zahlen */}
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
{showSummary ? (
<p className="text-sm text-gray-700 dark:text-gray-300">
Showing <span className="font-medium">{from}</span> to{' '}
<span className="font-medium">{to}</span> of{' '}
<span className="font-medium">{totalItems}</span> results
</p>
) : null}
</div>
<div>
<nav
aria-label={ariaLabel}
className="isolate inline-flex -space-x-px rounded-md shadow-xs dark:shadow-none"
>
<button
type="button"
onClick={() => go(current - 1)}
disabled={current <= 1}
className="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{prevLabel}</span>
<ChevronLeftIcon aria-hidden="true" className="size-5" />
</button>
{items.map((it, idx) => {
if (it === 'ellipsis') {
return (
<span
key={`e-${idx}`}
className="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 inset-ring inset-ring-gray-300 dark:text-gray-400 dark:inset-ring-gray-700"
>
</span>
)
}
return (
<PageButton
key={it}
active={it === current}
onClick={() => go(it)}
rounded="none"
>
{it}
</PageButton>
)
})}
<button
type="button"
onClick={() => go(current + 1)}
disabled={current >= totalPages}
className="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 inset-ring inset-ring-gray-300 hover:bg-gray-50 focus:z-20 focus:outline-offset-0 disabled:opacity-50 disabled:cursor-not-allowed dark:inset-ring-gray-700 dark:hover:bg-white/5"
>
<span className="sr-only">{nextLabel}</span>
<ChevronRightIcon aria-hidden="true" className="size-5" />
</button>
</nav>
</div>
</div>
</div>
)
}

View File

@ -9,19 +9,56 @@ import Button from './Button'
import videojs from 'video.js'
import type VideoJsPlayer from 'video.js/dist/types/player'
import 'video.js/dist/video-js.css'
import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from '@heroicons/react/24/outline'
import { createPortal } from 'react-dom'
import {
ArrowsPointingOutIcon,
ArrowsPointingInIcon,
FireIcon,
HeartIcon,
HandThumbUpIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
}
export type PlayerProps = {
job: RecordJob
expanded: boolean
onClose: () => void
onToggleExpand: () => void
className?: string
// states für Buttons
isHot?: boolean
isFavorite?: boolean
isLiked?: boolean
// actions
onDelete?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
}
export default function Player({ job, expanded, onClose, onToggleExpand, className }: PlayerProps) {
export default function Player({
job,
expanded,
onClose,
onToggleExpand,
className,
isHot = false,
isFavorite = false,
isLiked = false,
onDelete,
onToggleHot,
onToggleFavorite,
onToggleLike,
}: PlayerProps) {
const title = React.useMemo(() => baseName(job.output?.trim() || '') || job.id, [job.output, job.id])
React.useEffect(() => {
@ -30,18 +67,37 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const src = React.useMemo(() => {
const media = React.useMemo(() => {
// Running: LIVE über Preview-HLS
if (job.status === 'running') {
return {
src: `/api/record/preview?id=${encodeURIComponent(job.id)}&file=index_hq.m3u8`,
type: 'application/x-mpegURL',
}
}
const file = baseName(job.output?.trim() || '')
if (file && job.status !== 'running') return `/api/record/video?file=${encodeURIComponent(file)}`
return `/api/record/video?id=${encodeURIComponent(job.id)}`
if (file) {
return {
src: `/api/record/video?file=${encodeURIComponent(file)}`,
type: 'video/mp4',
}
}
// Fallback (nur solange Job im RAM existiert)
return { src: `/api/record/video?id=${encodeURIComponent(job.id)}`, type: 'video/mp4' }
}, [job.id, job.output, job.status])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
const videoNodeRef = React.useRef<HTMLVideoElement | null>(null)
// ✅ 1x initialisieren (Element wird sicher in den DOM gehängt)
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => setMounted(true), [])
// ✅ 1x initialisieren
React.useLayoutEffect(() => {
if (!mounted) return
if (!containerRef.current) return
if (playerRef.current) return
@ -52,8 +108,9 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
containerRef.current.appendChild(videoEl)
videoNodeRef.current = videoEl
playerRef.current = videojs(videoEl, {
const p = videojs(videoEl, {
autoplay: true,
muted: true, // <- wichtig für Autoplay ohne Klick in vielen Browsern
controls: true,
preload: 'auto',
playsinline: true,
@ -76,100 +133,207 @@ export default function Player({ job, expanded, onClose, onToggleExpand, classNa
playbackRates: [0.5, 1, 1.25, 1.5, 2],
})
playerRef.current = p
return () => {
if (playerRef.current) {
playerRef.current.dispose()
playerRef.current = null
}
if (videoNodeRef.current) {
videoNodeRef.current.remove()
videoNodeRef.current = null
try {
if (playerRef.current) {
playerRef.current.dispose()
playerRef.current = null
}
} finally {
if (videoNodeRef.current) {
videoNodeRef.current.remove()
videoNodeRef.current = null
}
}
}
}, [])
}, [mounted])
// ✅ Source wechseln ohne Remount
// ✅ Source setzen + immer versuchen zu starten
React.useEffect(() => {
if (!mounted) return
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const wasPlaying = !p.paused()
const t = p.currentTime() || 0
p.src({ src, type: 'video/mp4' })
// Autoplay-Policy: vor play() immer muted setzen
p.muted(true)
// Source setzen
p.src({ src: media.src, type: media.type })
const tryPlay = () => {
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') {
;(ret as Promise<void>).catch(() => {
// ok: Browser blockt evtl. Autoplay trotz muted (selten)
})
}
}
p.one('loadedmetadata', () => {
if ((p as any).isDisposed?.()) return
if (t > 0) p.currentTime(t)
if (wasPlaying) {
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') {
;(ret as Promise<void>).catch(() => {})
}
}
})
}, [src])
// ✅ bei Größenwechsel kurz resize triggern (hilft manchmal)
// bei HLS live nicht versuchen zu seeken
if (t > 0 && media.type !== 'application/x-mpegURL') p.currentTime(t)
tryPlay()
})
// zusätzlich sofort versuchen (hilft je nach Browser/Cache)
tryPlay()
}, [mounted, media.src, media.type])
// ✅ Resize triggern bei expand/collapse
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
queueMicrotask(() => p.trigger('resize'))
}, [expanded])
return (
<Card
className={[
'fixed z-50 shadow-xl border',
expanded ? 'inset-6' : 'bottom-4 right-4 w-[360px]',
'flex flex-col',
className ?? '',
].join(' ')}
noBodyPadding
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
header={
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{title}</div>
if (!mounted) return null
const mini = !expanded
const footerRight = (
<div className="flex items-center gap-1">
<Button
variant={isHot ? 'soft' : 'secondary'}
size="xs"
className={cn(
'px-2 py-1',
!isHot && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
onClick={() => onToggleHot?.(job)}
disabled={!onToggleHot}
>
<FireIcon className="h-4 w-4" />
</Button>
<Button
variant={isFavorite ? 'soft' : 'secondary'}
size="xs"
className={cn(
'px-2 py-1',
!isFavorite && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
aria-label={isFavorite ? 'Favorite entfernen' : 'Als Favorite markieren'}
onClick={() => onToggleFavorite?.(job)}
disabled={!onToggleFavorite}
>
<HeartIcon className="h-4 w-4" />
</Button>
<Button
variant={isLiked ? 'soft' : 'secondary'}
size="xs"
className={cn(
'px-2 py-1',
!isLiked && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
aria-label={isLiked ? 'Like entfernen' : 'Like hinzufügen'}
onClick={() => onToggleLike?.(job)}
disabled={!onToggleLike}
>
<HandThumbUpIcon className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="xs"
className="px-2 py-1 bg-transparent shadow-none hover:bg-red-50 text-red-600 dark:hover:bg-red-500/10 dark:text-red-400"
title="Löschen"
aria-label="Löschen"
onClick={() => onDelete?.(job)}
disabled={!onDelete}
>
<TrashIcon className="h-4 w-4" />
</Button>
</div>
)
return createPortal(
<>
{expanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={onClose}
aria-hidden="true"
/>
)}
<Card
className={cn(
'fixed z-50 shadow-xl border flex flex-col',
expanded ? 'inset-6' : 'bottom-4 right-4 w-[380px]',
className ?? ''
)}
noBodyPadding
bodyClassName="flex flex-col flex-1 min-h-0 p-0"
header={
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
{title}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="secondary"
size="xs"
className="px-2 py-1"
onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
</Button>
<Button
variant="secondary"
size="xs"
className="px-2 py-1"
onClick={onClose}
title="Schließen"
aria-label="Schließen"
>
<XMarkIcon className="h-5 w-5" />
</Button>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
className="px-2 py-1 rounded"
onClick={onToggleExpand}
aria-label={expanded ? 'Minimieren' : 'Maximieren'}
title={expanded ? 'Minimieren' : 'Maximieren'}
>
{expanded ? (
<ArrowsPointingInIcon className="h-5 w-5" />
) : (
<ArrowsPointingOutIcon className="h-5 w-5" />
)}
</Button>
<Button className="px-2 py-1 rounded text-sm" onClick={onClose} title="Schließen">
</Button>
}
footer={
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
<div className="min-w-0 truncate">
Status: <span className="font-medium">{job.status}</span>
{job.output ? <span className="ml-2 opacity-70"> {job.output}</span> : null}
</div>
{/* Buttons primär im Mini-Player */}
{mini ? footerRight : null}
</div>
}
grayBody={false}
>
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
<div className={cn('w-full h-full min-h-0', mini && 'vjs-mini')}>
<div ref={containerRef} className="w-full h-full" />
</div>
</div>
}
footer={
<div className="flex items-center justify-between gap-3 text-xs text-gray-600 dark:text-gray-300">
<div className="min-w-0 truncate">
Status: <span className="font-medium">{job.status}</span>
{job.output ? <span className="ml-2 opacity-70"> {job.output}</span> : null}
</div>
{job.output ? (
<button className="hover:underline" onClick={() => navigator.clipboard.writeText(job.output || '')}>
Pfad kopieren
</button>
) : null}
</div>
}
grayBody={false}
>
<div className={expanded ? 'flex-1 min-h-0' : 'aspect-video'}>
<div className={['w-full h-full min-h-0', !expanded ? 'vjs-mini' : ''].join(' ')}>
<div ref={containerRef} className="w-full h-full" />
</div>
</div>
</Card>
</Card>
</>,
document.body
)
}

View File

@ -14,6 +14,9 @@ type RecorderSettings = {
// ✅ neue Optionen
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean
}
const DEFAULTS: RecorderSettings = {
@ -25,6 +28,8 @@ const DEFAULTS: RecorderSettings = {
// ✅ defaults für switches
autoAddToDownloadList: true,
autoStartAddedDownloads: true,
useChaturbateApi: false,
}
export default function RecorderSettings() {
@ -46,11 +51,14 @@ export default function RecorderSettings() {
setValue({
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
ffmpegPath: (data.ffmpegPath ?? DEFAULTS.ffmpegPath).toString(),
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
// ✅ falls backend die Felder noch nicht hat -> defaults nutzen
autoAddToDownloadList: data.autoAddToDownloadList ?? DEFAULTS.autoAddToDownloadList,
autoStartAddedDownloads: data.autoStartAddedDownloads ?? DEFAULTS.autoStartAddedDownloads,
useChaturbateApi: data.useChaturbateApi ?? DEFAULTS.useChaturbateApi,
})
})
.catch(() => {
@ -108,6 +116,8 @@ export default function RecorderSettings() {
const autoAddToDownloadList = !!value.autoAddToDownloadList
const autoStartAddedDownloads = autoAddToDownloadList ? !!value.autoStartAddedDownloads : false
const useChaturbateApi = !!value.useChaturbateApi
setSaving(true)
try {
const res = await fetch('/api/settings', {
@ -117,8 +127,10 @@ export default function RecorderSettings() {
recordDir,
doneDir,
ffmpegPath,
autoAddToDownloadList: value.autoAddToDownloadList,
autoStartAddedDownloads: value.autoStartAddedDownloads,
autoAddToDownloadList,
autoStartAddedDownloads,
useChaturbateApi,
}),
})
if (!res.ok) {
@ -126,6 +138,7 @@ export default function RecorderSettings() {
throw new Error(t || `HTTP ${res.status}`)
}
setMsg('✅ Gespeichert.')
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
@ -236,6 +249,13 @@ export default function RecorderSettings() {
label="Hinzugefügte Downloads automatisch starten"
description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)."
/>
<LabeledSwitch
checked={!!value.useChaturbateApi}
onChange={(checked) => setValue((v) => ({ ...v, useChaturbateApi: checked }))}
label="Chaturbate API"
description="Wenn aktiv, pollt das Backend alle paar Sekunden die Online-Rooms API und cached die aktuell online Models."
/>
</div>
</div>
</div>

View File

@ -1,15 +1,21 @@
// RunningDownloads.tsx
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import WaitingModelsTable, { type WaitingModelRow } from './WaitingModelsTable'
import type { RecordJob } from '../../types'
type PendingWatchedRoom = WaitingModelRow & {
currentShow: string // public / private / hidden / away / unknown
}
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void
}
@ -49,24 +55,13 @@ const runtimeOf = (j: RecordJob) => {
return formatDuration(end - start)
}
export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Props) {
// globaler Tick für alle Thumbnails
const [thumbTick, setThumbTick] = useState(0)
useEffect(() => {
const id = window.setInterval(() => {
setThumbTick((t) => t + 1)
}, 5000) // alle 5s
return () => window.clearInterval(id)
}, [])
export default function RunningDownloads({ jobs, pending = [], onOpenPlayer, onStopJob }: Props) {
const columns = useMemo<Column<RecordJob>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
cell: (j) => <ModelPreview jobId={j.id} thumbTick={thumbTick} />,
cell: (j) => <ModelPreview jobId={j.id} />,
},
{
key: 'model',
@ -125,105 +120,127 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop
),
},
]
}, [onStopJob, thumbTick])
}, [onStopJob])
if (jobs.length === 0) {
if (jobs.length === 0 && pending.length === 0) {
return (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Keine laufenden Downloads.
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">Keine laufenden Downloads.</div>
</Card>
)
}
return (
<>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{jobs.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
return (
<div
key={j.id}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<Card
header={
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">
{model}
</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
{file || '—'}
</div>
</div>
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation()
onStopJob(j.id)
}}
>
Stop
</Button>
</div>
}
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} thumbTick={thumbTick} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{j.status}</span>
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
</Card>
{pending.length > 0 && (
<Card
grayBody
header={
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Online (wartend Download startet nur bei <span className="font-semibold">public</span>)
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">{pending.length}</div>
</div>
)
})}
</div>
}
>
<WaitingModelsTable models={pending} />
</Card>
)}
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={jobs}
columns={columns}
getRowKey={(r) => r.id}
striped
fullWidth
onRowClick={onOpenPlayer}
/>
</div>
{jobs.length === 0 && pending.length > 0 && (
<Card grayBody>
<div className="text-sm text-gray-600 dark:text-gray-300">
Kein aktiver Download wartet auf public.
</div>
</Card>
)}
{jobs.length > 0 && (
<>
{/* ✅ Mobile: Cards */}
<div className="sm:hidden space-y-3">
{jobs.map((j) => {
const model = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const dur = runtimeOf(j)
return (
<div
key={j.id}
role="button"
tabIndex={0}
className="cursor-pointer"
onClick={() => onOpenPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
}}
>
<Card
header={
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{model}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">{file || '—'}</div>
</div>
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation()
onStopJob(j.id)
}}
>
Stop
</Button>
</div>
}
>
<div className="flex gap-3">
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<ModelPreview jobId={j.id} />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{j.status}</span>
<span className="mx-2 opacity-60"></span>
Dauer: <span className="font-medium">{dur}</span>
</div>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block truncate text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : null}
</div>
</div>
</Card>
</div>
)
})}
</div>
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table
rows={jobs}
columns={columns}
getRowKey={(r) => r.id}
striped
fullWidth
onRowClick={onOpenPlayer}
/>
</div>
</>
)}
</>
)
}

View File

@ -1,7 +1,13 @@
'use client'
import * as React from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
type Align = 'left' | 'center' | 'right'
export type SortDirection = 'asc' | 'desc'
export type SortState = { key: string; direction: SortDirection } | null
export type Column<T> = {
key: string
header: React.ReactNode
@ -9,12 +15,24 @@ export type Column<T> = {
align?: Align
className?: string
headerClassName?: string
/** Wenn gesetzt: so wird die Zelle gerendert */
cell?: (row: T, rowIndex: number) => React.ReactNode
/** Standard: row[col.key] */
accessor?: (row: T) => React.ReactNode
/** Optional: sr-only Header (z.B. für Action-Spalte) */
srOnlyHeader?: boolean
/** Sortieren aktivieren (Header wird klickbar) */
sortable?: boolean
/**
* Wert, nach dem sortiert werden soll (empfohlen!),
* z.B. number/string/Date/boolean.
* Fallback ist row[col.key].
*/
sortValue?: (row: T) => string | number | boolean | Date | null | undefined
/** Optional: komplett eigene Vergleichsfunktion */
sortFn?: (a: T, b: T) => number
}
export type TableProps<T> = {
@ -40,9 +58,21 @@ export type TableProps<T> = {
className?: string
onRowClick?: (row: T) => void
onRowContextMenu?: (row: T, e: React.MouseEvent<HTMLTableRowElement>) => void
/**
* Controlled Sorting:
* - sort: aktueller Sort-Status
* - onSortChange: wird bei Klick auf Header aufgerufen
*/
sort?: SortState
onSortChange?: (next: SortState) => void
/**
* Uncontrolled Sorting:
* Initiale Sortierung (wenn sort nicht gesetzt ist)
*/
defaultSort?: SortState
}
function cn(...parts: Array<string | false | null | undefined>) {
@ -55,6 +85,22 @@ function alignTd(a?: Align) {
return 'text-left'
}
function justifyForAlign(a?: Align) {
if (a === 'center') return 'justify-center'
if (a === 'right') return 'justify-end'
return 'justify-start'
}
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
if (typeof v === 'number') return { isNull: false, kind: 'number', value: v }
if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 }
if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) }
// strings & sonstiges
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
}
export default function Table<T>({
columns,
rows,
@ -71,11 +117,60 @@ export default function Table<T>({
emptyLabel = 'Keine Daten vorhanden.',
className,
onRowClick,
onRowContextMenu
onRowContextMenu,
sort,
onSortChange,
defaultSort = null,
}: TableProps<T>) {
const cellY = compact ? 'py-2' : 'py-4'
const headY = compact ? 'py-3' : 'py-3.5'
const isControlled = sort !== undefined
const [internalSort, setInternalSort] = React.useState<SortState>(defaultSort)
const sortState = isControlled ? sort! : internalSort
const setSortState = React.useCallback(
(next: SortState) => {
if (!isControlled) setInternalSort(next)
onSortChange?.(next)
},
[isControlled, onSortChange]
)
const sortedRows = React.useMemo(() => {
if (!sortState) return rows
const col = columns.find((c) => c.key === sortState.key)
if (!col) return rows
const dirMul = sortState.direction === 'asc' ? 1 : -1
const decorated = rows.map((r, i) => ({ r, i }))
decorated.sort((x, y) => {
let res = 0
if (col.sortFn) {
res = col.sortFn(x.r, y.r)
} else {
const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key]
const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key]
const na = normalizeSortValue(ax)
const nb = normalizeSortValue(by)
// nulls immer nach hinten
if (na.isNull && !nb.isNull) res = 1
else if (!na.isNull && nb.isNull) res = -1
else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0
else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true })
}
if (res === 0) return x.i - y.i // stable
return res * dirMul
})
return decorated.map((d) => d.r)
}, [rows, columns, sortState])
return (
<div className={cn(fullWidth ? '' : 'px-4 sm:px-6 lg:px-8', className)}>
{(title || description || actions) && (
@ -112,71 +207,109 @@ export default function Table<T>({
<thead
className={cn(
card && 'bg-gray-50 dark:bg-gray-800/75',
stickyHeader &&
'sticky top-0 z-10 backdrop-blur-sm backdrop-filter'
stickyHeader && 'sticky top-0 z-10 backdrop-blur-sm backdrop-filter'
)}
>
<tr>
{columns.map((col) => (
<th
key={col.key}
scope="col"
className={cn(
headY,
'px-3 text-sm font-semibold text-gray-900 dark:text-gray-200',
alignTd(col.align),
col.widthClassName,
col.headerClassName
)}
>
{col.srOnlyHeader ? (
<span className="sr-only">{col.header}</span>
) : (
col.header
)}
</th>
))}
{columns.map((col) => {
const isSorted = !!sortState && sortState.key === col.key
const dir = isSorted ? sortState!.direction : undefined
const ariaSort =
col.sortable && !col.srOnlyHeader
? isSorted
? dir === 'asc'
? 'ascending'
: 'descending'
: 'none'
: undefined
const nextSort = () => {
if (!col.sortable || col.srOnlyHeader) return
if (!isSorted) return setSortState({ key: col.key, direction: 'asc' })
if (dir === 'asc') return setSortState({ key: col.key, direction: 'desc' })
return setSortState(null)
}
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort as any}
className={cn(
headY,
'px-3 text-sm font-semibold text-gray-900 dark:text-gray-200',
alignTd(col.align),
col.widthClassName,
col.headerClassName
)}
>
{col.srOnlyHeader ? (
<span className="sr-only">{col.header}</span>
) : col.sortable ? (
<button
type="button"
onClick={nextSort}
className={cn(
'group inline-flex w-full items-center gap-2 select-none',
justifyForAlign(col.align)
)}
>
<span>{col.header}</span>
<span
className={cn(
'flex-none rounded-sm text-gray-400 dark:text-gray-500',
isSorted
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white'
: 'invisible group-hover:visible group-focus-visible:visible'
)}
>
<ChevronDownIcon
aria-hidden="true"
className={cn(
'size-5 transition-transform',
isSorted && dir === 'asc' && 'rotate-180'
)}
/>
</span>
</button>
) : (
col.header
)}
</th>
)
})}
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-gray-200 dark:divide-white/10',
card
? 'bg-white dark:bg-gray-800/50'
: 'bg-white dark:bg-gray-900'
card ? 'bg-white dark:bg-gray-800/50' : 'bg-white dark:bg-gray-900'
)}
>
{isLoading ? (
<tr>
<td
colSpan={columns.length}
className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}
>
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}>
Lädt
</td>
</tr>
) : rows.length === 0 ? (
) : sortedRows.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}
>
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}>
{emptyLabel}
</td>
</tr>
) : (
rows.map((row, rowIndex) => {
const key = getRowKey
? getRowKey(row, rowIndex)
: String(rowIndex)
sortedRows.map((row, rowIndex) => {
const key = getRowKey ? getRowKey(row, rowIndex) : String(rowIndex)
return (
<tr
key={key}
className={cn(
striped && 'even:bg-gray-50 dark:even:bg-gray-800/50',
onRowClick && "cursor-pointer"
onRowClick && 'cursor-pointer'
)}
onClick={() => onRowClick?.(row)}
onContextMenu={
@ -202,7 +335,6 @@ export default function Table<T>({
'px-3 text-sm whitespace-nowrap',
alignTd(col.align),
col.className,
// “Primäre” Spalte wirkt wie in den Beispielen: etwas stärker
col.key === columns[0]?.key
? 'font-medium text-gray-900 dark:text-white'
: 'text-gray-500 dark:text-gray-400'

View File

@ -0,0 +1,91 @@
// WaitingModelsTable.tsx
'use client'
import { useMemo } from 'react'
import Table, { type Column } from './Table'
export type WaitingModelRow = {
id: string
modelKey: string
url: string
currentShow?: string // public / private / hidden / away / unknown
}
type Props = {
models: WaitingModelRow[]
}
export default function WaitingModelsTable({ models }: Props) {
const columns = useMemo<Column<WaitingModelRow>[]>(() => {
return [
{
key: 'model',
header: 'Model',
cell: (m) => (
<span className="truncate font-medium text-gray-900 dark:text-white" title={m.modelKey}>
{m.modelKey}
</span>
),
},
{
key: 'status',
header: 'Status',
cell: (m) => <span className="font-medium">{m.currentShow || 'unknown'}</span>,
},
{
key: 'open',
header: 'Öffnen',
srOnlyHeader: true,
align: 'right',
cell: (m) => (
<a
href={m.url}
target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
Öffnen
</a>
),
},
]
}, [])
if (models.length === 0) return null
return (
<>
{/* ✅ Mobile: kompakte Liste */}
<div className="sm:hidden space-y-2">
{models.map((m) => (
<div
key={m.id}
className="flex items-center justify-between gap-3 rounded-md bg-white/60 dark:bg-white/5 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-900 dark:text-white">{m.modelKey}</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
Status: <span className="font-medium">{m.currentShow || 'unknown'}</span>
</div>
</div>
<a
href={m.url}
target="_blank"
rel="noreferrer"
className="shrink-0 text-xs text-indigo-600 dark:text-indigo-400 hover:underline"
>
Öffnen
</a>
</div>
))}
</div>
{/* ✅ Desktop/Tablet: Tabelle */}
<div className="hidden sm:block">
<Table rows={models} columns={columns} getRowKey={(r) => r.id} striped fullWidth />
</div>
</>
)
}

View File

@ -10,9 +10,10 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
host: true, // oder '0.0.0.0'
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://10.0.1.25:9999',
changeOrigin: true,
},
},