updated
This commit is contained in:
parent
567ac96bad
commit
05c9d04db9
263
backend/chaturbate_online.go
Normal file
263
backend/chaturbate_online.go
Normal 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
86
backend/cookies_api.go
Normal 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
149
backend/cookies_crypto.go
Normal 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
2
backend/data/cookies.key
Normal file
@ -0,0 +1,2 @@
|
||||
Gæ‚Y$ÓO¦þaß{æÏxk‘PTÀ
|
||||
ÕiPGkkØU
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
409
backend/main.go
409
backend/main.go
@ -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 == "" {
|
||||
|
||||
@ -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"})
|
||||
|
||||
@ -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, ¬null, &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
BIN
backend/nsfwapp.exe
Normal file
Binary file not shown.
1
backend/web/dist/assets/index-WtXLd9dH.css
vendored
Normal file
1
backend/web/dist/assets/index-WtXLd9dH.css
vendored
Normal file
File diff suppressed because one or more lines are too long
257
backend/web/dist/assets/index-iDPthw87.js
vendored
Normal file
257
backend/web/dist/assets/index-iDPthw87.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
backend/web/dist/index.html
vendored
Normal file
14
backend/web/dist/index.html
vendored
Normal 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
1
backend/web/dist/vite.svg
vendored
Normal 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 |
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
262
frontend/src/components/ui/Pagination.tsx
Normal file
262
frontend/src/components/ui/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
91
frontend/src/components/ui/WaitingModelsTable.tsx
Normal file
91
frontend/src/components/ui/WaitingModelsTable.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user