This commit is contained in:
Linrador 2026-03-06 14:50:56 +01:00
parent 81f02c9941
commit c944483fe6
36 changed files with 2534 additions and 2187 deletions

View File

@ -398,6 +398,77 @@ func cbApplySnapshot(rooms []ChaturbateRoom) time.Time {
return fetchedAtNow return fetchedAtNow
} }
func buildSnapshotFromRoom(rm ChaturbateRoom) *ChaturbateOnlineSnapshot {
return &ChaturbateOnlineSnapshot{
Username: strings.TrimSpace(rm.Username),
DisplayName: strings.TrimSpace(rm.DisplayName),
CurrentShow: strings.TrimSpace(rm.CurrentShow),
RoomSubject: strings.TrimSpace(rm.RoomSubject),
Location: strings.TrimSpace(rm.Location),
Country: strings.TrimSpace(rm.Country),
SpokenLanguages: strings.TrimSpace(rm.SpokenLanguages),
Gender: strings.TrimSpace(rm.Gender),
NumUsers: rm.NumUsers,
NumFollowers: rm.NumFollowers,
IsHD: rm.IsHD,
IsNew: rm.IsNew,
Age: rm.Age,
SecondsOnline: rm.SecondsOnline,
ImageURL: strings.TrimSpace(rm.ImageURL),
ImageURL360: strings.TrimSpace(rm.ImageURL360),
ChatRoomURL: strings.TrimSpace(rm.ChatRoomURL),
ChatRoomURLRS: strings.TrimSpace(rm.ChatRoomURLRS),
Tags: rm.Tags,
}
}
func buildLiteFromStoredSnapshot(userLower string, snap *ChaturbateOnlineSnapshot) ChaturbateOnlineRoomLite {
if snap == nil {
return ChaturbateOnlineRoomLite{
Username: userLower,
CurrentShow: "offline",
ChatRoomURL: "https://chaturbate.com/" + userLower + "/",
ImageURL: "",
}
}
username := strings.TrimSpace(snap.Username)
if username == "" {
username = userLower
}
show := strings.ToLower(strings.TrimSpace(snap.CurrentShow))
if show == "" {
show = "offline"
}
chatURL := strings.TrimSpace(snap.ChatRoomURL)
if chatURL == "" {
chatURL = "https://chaturbate.com/" + username + "/"
}
img := strings.TrimSpace(snap.ImageURL360)
if img == "" {
img = strings.TrimSpace(snap.ImageURL)
}
return ChaturbateOnlineRoomLite{
Username: username,
CurrentShow: show,
ChatRoomURL: chatURL,
ImageURL: img,
Gender: strings.TrimSpace(snap.Gender),
Country: strings.TrimSpace(snap.Country),
NumUsers: snap.NumUsers,
IsHD: snap.IsHD,
Tags: snap.Tags,
}
}
// startChaturbateOnlinePoller pollt die API alle paar Sekunden, // startChaturbateOnlinePoller pollt die API alle paar Sekunden,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller(store *ModelStore) { func startChaturbateOnlinePoller(store *ModelStore) {
@ -454,6 +525,45 @@ func startChaturbateOnlinePoller(store *ModelStore) {
_ = cbApplySnapshot(rooms) _ = cbApplySnapshot(rooms)
fetchedAt := time.Now().UTC().Format(time.RFC3339Nano)
// ✅ DB-Infos aktualisieren (nur für bereits vorhandene Models)
if cbModelStore != nil {
// map online rooms by username
roomsByUser := indexRoomsByUser(rooms)
// alle modelKeys für chaturbate.com holen
keys, err := cbModelStore.ListModelKeysByHost("chaturbate.com")
if err == nil && len(keys) > 0 {
for _, key := range keys {
// key ist lowercased
rm, ok := roomsByUser[key]
if ok {
// online -> status + snapshot
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", key, true, fetchedAt)
_ = cbModelStore.SetChaturbateOnlineSnapshot("chaturbate.com", key, buildSnapshotFromRoom(rm), fetchedAt, "")
// optional: tags füllen (aber nur wenn leer hast du eh schon)
// optional: profile_image_url updaten (URL-only) wenn du willst:
imgURL := selectBestRoomImageURL(rm)
if imgURL != "" {
_ = cbModelStore.SetProfileImageURLOnly("chaturbate.com", key, imgURL, fetchedAt)
}
} else {
// offline -> status + snapshot löschen (oder behalten)
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", key, false, fetchedAt)
// Ich empfehle: Snapshot behalten, aber CurrentShow/offline kannst du so ausdrücken:
// -> entweder NULL setzen oder offline-snapshot speichern
_ = cbModelStore.SetChaturbateOnlineSnapshot("chaturbate.com", key, &ChaturbateOnlineSnapshot{
Username: key,
CurrentShow: "offline",
}, fetchedAt, "")
}
}
}
}
// Tags übernehmen ist teuer -> nur selten + im Hintergrund // Tags übernehmen ist teuer -> nur selten + im Hintergrund
if cbModelStore != nil && len(rooms) > 0 { if cbModelStore != nil && len(rooms) > 0 {
shouldFill := false shouldFill := false
@ -602,7 +712,7 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
return return
} }
changedAny := false changedIDs := make([]string, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
@ -622,18 +732,19 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
// Wenn ffmpeg schon läuft und sich Quelle geändert hat -> hart stoppen // Wenn ffmpeg schon läuft und sich Quelle geändert hat -> hart stoppen
if old != "" && old != newHls { if old != "" && old != newHls {
stopPreview(j) stopPreview(j)
// PreviewState zurücksetzen (damit "private/offline" nicht hängen bleibt)
j.PreviewState = "" j.PreviewState = ""
j.PreviewStateAt = "" j.PreviewStateAt = ""
j.PreviewStateMsg = "" j.PreviewStateMsg = ""
} }
changedAny = true if strings.TrimSpace(j.ID) != "" {
changedIDs = append(changedIDs, j.ID)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
if changedAny { for _, id := range changedIDs {
notifyJobsChanged() notifyJobPatched(id)
} }
} }
@ -849,6 +960,18 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser liteByUser := cb.LiteByUser
cbMu.RUnlock() cbMu.RUnlock()
// DB-Fallback für bereits bekannte Models
dbLiteByUser := map[string]ChaturbateOnlineRoomLite{}
if cbModelStore != nil && onlySpecificUsers {
for _, u := range users {
snap, _, ok, err := cbModelStore.GetChaturbateOnlineSnapshot("chaturbate.com", u)
if err != nil || !ok || snap == nil {
continue
}
dbLiteByUser[u] = buildLiteFromStoredSnapshot(u, snap)
}
}
// --------------------------- // ---------------------------
// ✅ HLS URL Refresh für laufende Jobs (best effort) // ✅ HLS URL Refresh für laufende Jobs (best effort)
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so) // Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
@ -891,10 +1014,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
// --------------------------- // ---------------------------
// Persist "last seen online/offline" für explizit angefragte User // Persist "last seen online/offline" für explizit angefragte User
// --------------------------- // ---------------------------
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() { if cbModelStore != nil && onlySpecificUsers && !fetchedAt.IsZero() {
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano) seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
for _, u := range users { for _, u := range users {
_, isOnline := liteByUser[u] isOnline := false
if liteByUser != nil {
_, isOnline = liteByUser[u]
}
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt) _ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
} }
} }
@ -1020,20 +1146,61 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
outRooms := make([]outRoom, 0, len(users)) outRooms := make([]outRoom, 0, len(users))
if onlySpecificUsers && liteByUser != nil { if onlySpecificUsers {
for _, u := range users { for _, u := range users {
rm, ok := liteByUser[u] var (
if !ok { rm ChaturbateOnlineRoomLite
continue ok bool
)
// 1) bevorzugt echter API-Live-Snapshot
if liteByUser != nil {
rm, ok = liteByUser[u]
} }
// 2) fallback auf DB-persistierten Snapshot
if !ok {
if dbRm, dbOK := dbLiteByUser[u]; dbOK {
rm = dbRm
ok = true
}
}
// 3) wenn gar nichts da ist: offline-Minimalobjekt
if !ok {
rm = ChaturbateOnlineRoomLite{
Username: u,
CurrentShow: "offline",
ChatRoomURL: "https://chaturbate.com/" + u + "/",
ImageURL: "",
}
ok = true
}
if !matches(rm) { if !matches(rm) {
continue continue
} }
username := strings.TrimSpace(rm.Username)
if username == "" {
username = u
}
currentShow := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if currentShow == "" {
currentShow = "offline"
}
chatRoomURL := strings.TrimSpace(rm.ChatRoomURL)
if chatRoomURL == "" {
chatRoomURL = "https://chaturbate.com/" + username + "/"
}
outRooms = append(outRooms, outRoom{ outRooms = append(outRooms, outRoom{
Username: rm.Username, Username: username,
CurrentShow: rm.CurrentShow, CurrentShow: currentShow,
ChatRoomURL: rm.ChatRoomURL, ChatRoomURL: chatRoomURL,
ImageURL: rm.ImageURL, ImageURL: strings.TrimSpace(rm.ImageURL),
}) })
} }
} }

View File

@ -81,11 +81,13 @@ func stopJobsInternal(list []*RecordJob) {
} }
type payload struct { type payload struct {
jobID string
cmd *exec.Cmd cmd *exec.Cmd
cancel context.CancelFunc cancel context.CancelFunc
} }
pl := make([]payload, 0, len(list)) pl := make([]payload, 0, len(list))
changedIDs := make([]string, 0, len(list))
jobsMu.Lock() jobsMu.Lock()
for _, job := range list { for _, job := range list {
@ -94,12 +96,25 @@ func stopJobsInternal(list []*RecordJob) {
} }
job.Phase = "stopping" job.Phase = "stopping"
job.Progress = 10 job.Progress = 10
pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel})
pl = append(pl, payload{
jobID: job.ID,
cmd: job.previewCmd,
cancel: job.cancel,
})
job.previewCmd = nil job.previewCmd = nil
if strings.TrimSpace(job.ID) != "" {
changedIDs = append(changedIDs, job.ID)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress) // 1) UI sofort updaten
for _, id := range changedIDs {
notifyJobPatched(id)
}
for _, p := range pl { for _, p := range pl {
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
@ -110,7 +125,10 @@ func stopJobsInternal(list []*RecordJob) {
} }
} }
notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen // 2) optional nochmal patchen, falls danach noch weitere Felder geändert wurden
for _, id := range changedIDs {
notifyJobPatched(id)
}
} }
func stopAllStoppableJobs() int { func stopAllStoppableJobs() int {

View File

@ -31,7 +31,7 @@ import (
// It intentionally reuses existing globals/types from your backend (package main): // It intentionally reuses existing globals/types from your backend (package main):
// - jobs, jobsMu, RecordJob, JobRunning // - jobs, jobsMu, RecordJob, JobRunning
// - ffmpegPath, previewSem // - ffmpegPath, previewSem
// - notifyJobsChanged() // - notifyJobPatched(id), notifyJobsChanged()
// - assetIDForJob(job *RecordJob) string // - assetIDForJob(job *RecordJob) string
// - startLiveThumbWebPLoop(ctx, job) // - startLiveThumbWebPLoop(ctx, job)
// ============================================================ // ============================================================
@ -94,6 +94,10 @@ func maybeBlockHLSOnPreview(w http.ResponseWriter, r *http.Request, basePath, fi
// stopPreview stops the running ffmpeg HLS preview process for a job and resets state. // stopPreview stops the running ffmpeg HLS preview process for a job and resets state.
func stopPreview(job *RecordJob) { func stopPreview(job *RecordJob) {
if job == nil {
return
}
jobsMu.Lock() jobsMu.Lock()
cmd := job.previewCmd cmd := job.previewCmd
cancel := job.previewCancel cancel := job.previewCancel
@ -101,6 +105,7 @@ func stopPreview(job *RecordJob) {
job.previewCancel = nil job.previewCancel = nil
job.LiveThumbStarted = false job.LiveThumbStarted = false
job.PreviewDir = "" job.PreviewDir = ""
jobID := strings.TrimSpace(job.ID)
jobsMu.Unlock() jobsMu.Unlock()
if cancel != nil { if cancel != nil {
@ -109,6 +114,10 @@ func stopPreview(job *RecordJob) {
if cmd != nil && cmd.Process != nil { if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
} }
if jobID != "" {
notifyJobPatched(jobID)
}
} }
func recordPreviewLive(w http.ResponseWriter, r *http.Request) { func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
@ -358,8 +367,12 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewState = "" job.PreviewState = ""
job.PreviewStateAt = "" job.PreviewStateAt = ""
job.PreviewStateMsg = "" job.PreviewStateMsg = ""
jobID := strings.TrimSpace(job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if jobID != "" {
notifyJobPatched(jobID)
}
commonIn := []string{"-y"} commonIn := []string{"-y"}
if strings.TrimSpace(userAgent) != "" { if strings.TrimSpace(userAgent) != "" {
@ -427,17 +440,27 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewStateMsg = st job.PreviewStateMsg = st
} }
} }
jobID := strings.TrimSpace(job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if jobID != "" {
notifyJobPatched(jobID)
}
fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
} }
jobsMu.Lock() jobsMu.Lock()
notifyID := ""
if job.previewCmd == cmd { if job.previewCmd == cmd {
job.previewCmd = nil job.previewCmd = nil
notifyID = strings.TrimSpace(job.ID)
} }
jobsMu.Unlock() jobsMu.Unlock()
if notifyID != "" {
notifyJobPatched(notifyID)
}
}() }()
startLiveThumbWebPLoop(ctx, job) startLiveThumbWebPLoop(ctx, job)
@ -579,8 +602,13 @@ func ensurePreviewStarted(r *http.Request, job *RecordJob) {
job.PreviewDir = pdir job.PreviewDir = pdir
job.previewCancel = cancel job.previewCancel = cancel
job.previewLastHit = time.Now() job.previewLastHit = time.Now()
jobID := strings.TrimSpace(job.ID)
jobsMu.Unlock() jobsMu.Unlock()
if jobID != "" {
notifyJobPatched(jobID)
}
_ = startPreviewHLS(pctx, job, m3u8, pdir, cookie, ua) _ = startPreviewHLS(pctx, job, m3u8, pdir, cookie, ua)
} }

View File

@ -11,7 +11,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -21,16 +20,16 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/grafov/m3u8" "github.com/grafov/m3u8"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
) )
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
// wird von SSE (modelsMetaSnapshotJSON) genutzt
var modelStore *ModelStore
type JobStatus string type JobStatus string
const ( const (
@ -76,9 +75,11 @@ type RecordJob struct {
previewWebpAt time.Time `json:"-"` previewWebpAt time.Time `json:"-"`
previewGen bool `json:"-"` previewGen bool `json:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig) PreviewCookie string `json:"-"` // Cookie header (falls nötig)
PreviewUA string `json:"-"` // user-agent PreviewUA string `json:"-"` // user-agent
PreviewTick int64 `json:"previewTick,omitempty"` // ✅ Cache-Buster für /api/preview
LastPreviewPushMs int64 `json:"-"` // nur server-intern
previewCancel context.CancelFunc `json:"-"` previewCancel context.CancelFunc `json:"-"`
previewLastHit time.Time `json:"-"` previewLastHit time.Time `json:"-"`
@ -213,13 +214,6 @@ var (
jobsMu = sync.Mutex{} jobsMu = sync.Mutex{}
) )
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) }
func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) }
func startPreviewIdleKiller() { func startPreviewIdleKiller() {
t := time.NewTicker(5 * time.Second) t := time.NewTicker(5 * time.Second)
go func() { go func() {
@ -257,8 +251,6 @@ func init() {
initFFmpegSemaphores() initFFmpegSemaphores()
startAdaptiveSemController(context.Background()) startAdaptiveSemController(context.Background())
startPreviewIdleKiller() startPreviewIdleKiller()
initSSE()
} }
func publishJob(jobID string) bool { func publishJob(jobID string) bool {
@ -271,7 +263,6 @@ func publishJob(jobID string) bool {
j.Hidden = false j.Hidden = false
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
return true return true
} }
@ -499,58 +490,6 @@ func initFFmpegSemaphores() {
) )
} }
func startAdaptiveSemController(ctx context.Context) {
targetHi := 85.0
targetLo := 65.0
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetHi = f
}
}
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetLo = f
}
}
// Warmup (erste Messung kann 0 sein)
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
setLastCPUUsage(usage)
// Preview ist am teuersten → konservativ
if usage > targetHi {
previewSem.SetMax(previewSem.Max() - 1)
genSem.SetMax(genSem.Max() - 1)
thumbSem.SetMax(thumbSem.Max() - 1)
} else if usage < targetLo {
previewSem.SetMax(previewSem.Max() + 1)
genSem.SetMax(genSem.Max() + 1)
thumbSem.SetMax(thumbSem.Max() + 1)
}
// optional Debug:
// fmt.Printf("CPU %.1f%% -> preview=%d thumb=%d gen=%d\n", usage, previewSem.Max(), thumbSem.Max(), genSem.Max())
}
}
}()
}
type durEntry struct { type durEntry struct {
size int64 size int64
mod time.Time mod time.Time
@ -566,185 +505,6 @@ var startedAtFromFilenameRe = regexp.MustCompile(
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
) )
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx)
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
// ✅ statt LowDiskPauseBelowGB aus Settings
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
// ✅ optional, aber sehr hilfreich (Debug/UI)
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if genSem != nil {
sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()}
}
if previewSem != nil {
sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()}
}
if durSem != nil {
sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
// clamp: 1000..30000
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// hilfreich hinter nginx/proxies:
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
// sofort erstes Event schicken
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
// event: perf
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
// initial
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
// SSE Kommentar als Heartbeat
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes) // returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -784,6 +544,10 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
} }
func setJobPhase(job *RecordJob, phase string, progress int) { func setJobPhase(job *RecordJob, phase string, progress int) {
if job == nil {
return
}
if progress < 0 { if progress < 0 {
progress = 0 progress = 0
} }
@ -795,9 +559,6 @@ func setJobPhase(job *RecordJob, phase string, progress int) {
job.Phase = phase job.Phase = phase
job.Progress = progress job.Progress = progress
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
} }
func durationSecondsCached(ctx context.Context, path string) (float64, error) { func durationSecondsCached(ctx context.Context, path string) (float64, error) {
@ -1659,7 +1420,6 @@ func removeJobsByOutputBasename(file string) {
return return
} }
removed := false
jobsMu.Lock() jobsMu.Lock()
for id, j := range jobs { for id, j := range jobs {
if j == nil { if j == nil {
@ -1671,14 +1431,9 @@ func removeJobsByOutputBasename(file string) {
} }
if filepath.Base(out) == file { if filepath.Base(out) == file {
delete(jobs, id) delete(jobs, id)
removed = true
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if removed {
notifyJobsChanged()
}
} }
func renameJobsOutputBasename(oldFile, newFile string) { func renameJobsOutputBasename(oldFile, newFile string) {
@ -1688,7 +1443,6 @@ func renameJobsOutputBasename(oldFile, newFile string) {
return return
} }
changed := false
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
if j == nil { if j == nil {
@ -1700,14 +1454,9 @@ func renameJobsOutputBasename(oldFile, newFile string) {
} }
if filepath.Base(out) == oldFile { if filepath.Base(out) == oldFile {
j.Output = filepath.Join(filepath.Dir(out), newFile) j.Output = filepath.Join(filepath.Dir(out), newFile)
changed = true
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if changed {
notifyJobsChanged()
}
} }
// nimmt jetzt *HTTPClient entgegen // nimmt jetzt *HTTPClient entgegen

View File

@ -202,6 +202,7 @@ func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath str
// Größe immer mitgeben (macht Sort/Anzeige einfacher) // Größe immer mitgeben (macht Sort/Anzeige einfacher)
if job.SizeBytes <= 0 { if job.SizeBytes <= 0 {
job.SizeBytes = fi.Size() job.SizeBytes = fi.Size()
notifyJobPatched(job.ID)
} }
// Meta.json lesen/erzeugen (best effort) // Meta.json lesen/erzeugen (best effort)

View File

@ -29,14 +29,14 @@ type Model struct {
Liked *bool `json:"liked"` // nil = keine Angabe Liked *bool `json:"liked"` // nil = keine Angabe
} }
type modelStore struct { type jsonModelStore struct {
mu sync.Mutex mu sync.Mutex
path string path string
loaded bool loaded bool
items []Model items []Model
} }
var models = &modelStore{ var models = &jsonModelStore{
path: filepath.Join("data", "models.json"), path: filepath.Join("data", "models.json"),
} }

View File

@ -262,7 +262,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return return
} }
modelsWriteJSON(w, http.StatusOK, store.List())
// ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen:
// Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten.
list := store.List()
modelsWriteJSON(w, http.StatusOK, list)
}) })
// ✅ Profilbild-Blob aus DB ausliefern // ✅ Profilbild-Blob aus DB ausliefern

View File

@ -3,7 +3,9 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -25,6 +27,11 @@ type StoredModel struct {
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
// ✅ Chaturbate Online Snapshot (persistiert aus chaturbate_online.go)
CbOnlineJSON string `json:"cbOnlineJson,omitempty"`
CbOnlineFetchedAt string `json:"cbOnlineFetchedAt,omitempty"`
CbOnlineLastError string `json:"cbOnlineLastError,omitempty"`
ProfileImageURL string `json:"profileImageUrl,omitempty"` ProfileImageURL string `json:"profileImageUrl,omitempty"`
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=... ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
@ -776,15 +783,18 @@ UPDATE models SET
-- last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben -- last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
last_stream = CASE last_stream = CASE
WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6 WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
ELSE last_stream ELSE last_stream
END, END,
watching = CASE WHEN $7=true THEN true ELSE watching END, watching = CASE WHEN $7=true THEN true ELSE watching END,
favorite = CASE WHEN $8=true THEN true ELSE favorite END, favorite = CASE WHEN $8=true THEN true ELSE favorite END,
hot = CASE WHEN $9=true THEN true ELSE hot END, hot = CASE WHEN $9=true THEN true ELSE hot END,
keep = CASE WHEN $10=true THEN true ELSE keep END, keep = CASE WHEN $10=true THEN true ELSE keep END,
liked = CASE WHEN liked IS NULL AND $11 IS NOT NULL THEN $11 ELSE liked END, liked = CASE
WHEN liked IS NULL AND $11::boolean IS NOT NULL THEN $11::boolean
ELSE liked
END,
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
WHERE id = $13; WHERE id = $13;
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
return []StoredModel{} return []StoredModel{}
} }
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'') q1 := `
rows, err := s.db.Query(`
SELECT SELECT
id,input,is_url,host,path,model_key, id,
tags, last_stream, COALESCE(input,'') as input,
last_seen_online, last_seen_online_at, is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''), -- optional
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
@ -830,9 +849,41 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
`) `
q2 := `
SELECT
id,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
''::text as cb_online_last_error, -- fallback dummy
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
ORDER BY updated_at DESC;
`
rows, err := s.db.Query(q1)
if err != nil { if err != nil {
return []StoredModel{} // ✅ genau dein Fall: "Spalte existiert nicht" -> fallback
fmt.Println("models List query err (q1):", err)
rows, err = s.db.Query(q2)
if err != nil {
fmt.Println("models List query err (q2):", err)
return []StoredModel{}
}
} }
defer rows.Close() defer rows.Close()
@ -841,14 +892,17 @@ ORDER BY updated_at DESC;
for rows.Next() { for rows.Next() {
var ( var (
id, input, host, path, modelKey, tags string id, input, host, path, modelKey, tags string
isURL bool
isURL bool
lastStream sql.NullTime lastStream sql.NullTime
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
&id, &input, &isURL, &host, &path, &modelKey, &id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, &tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt, &lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, Hot: hot,
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)} return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)}
} }
type ChaturbateOnlineSnapshot struct {
Username string `json:"username"`
DisplayName string `json:"display_name,omitempty"`
CurrentShow string `json:"current_show,omitempty"` // public/private/hidden/away
RoomSubject string `json:"room_subject,omitempty"`
Location string `json:"location,omitempty"`
Country string `json:"country,omitempty"`
SpokenLanguages string `json:"spoken_languages,omitempty"`
Gender string `json:"gender,omitempty"`
NumUsers int `json:"num_users,omitempty"`
NumFollowers int `json:"num_followers,omitempty"`
IsHD bool `json:"is_hd,omitempty"`
IsNew bool `json:"is_new,omitempty"`
Age int `json:"age,omitempty"`
SecondsOnline int `json:"seconds_online,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ImageURL360 string `json:"image_url_360x270,omitempty"`
ChatRoomURL string `json:"chat_room_url,omitempty"`
ChatRoomURLRS string `json:"chat_room_url_revshare,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (s *ModelStore) ListModelKeysByHost(host string) ([]string, error) {
if err := s.ensureInit(); err != nil {
return nil, err
}
host = canonicalHost(host)
if host == "" {
return nil, errors.New("host fehlt")
}
rows, err := s.db.Query(`
SELECT model_key
FROM models
WHERE lower(trim(host)) = lower(trim($1));
`, host)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 128)
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
continue
}
k = strings.ToLower(strings.TrimSpace(k))
if k != "" {
out = append(out, k)
}
}
return out, nil
}
func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
var jsonStr string
if snap != nil {
b, err := json.Marshal(snap)
if err == nil {
jsonStr = strings.TrimSpace(string(b))
}
}
ft := parseRFC3339Nano(fetchedAt)
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
// NOTE: cb_online_last_error nur updaten, wenn Spalte existiert.
// Wenn du die optionale Spalte nicht anlegst: entferne die beiden Stellen.
_, err := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
cb_online_last_error=$3,
updated_at=$4
WHERE lower(trim(host)) = lower(trim($5))
AND lower(trim(model_key)) = lower(trim($6));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), strings.TrimSpace(lastErr), now, host, key)
if err != nil {
// falls cb_online_last_error nicht existiert -> fallback ohne die Spalte
_, err2 := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), now, host, key)
return err2
}
return nil
}
func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*ChaturbateOnlineSnapshot, string, bool, error) {
if err := s.ensureInit(); err != nil {
return nil, "", false, err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return nil, "", false, errors.New("host/modelKey fehlt")
}
var js sql.NullString
var fetchedAt sql.NullTime
err := s.db.QueryRow(`
SELECT cb_online_json, cb_online_fetched_at
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, key).Scan(&js, &fetchedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", false, nil
}
if err != nil {
return nil, "", false, err
}
raw := strings.TrimSpace(js.String)
if raw == "" {
return nil, fmtNullTime(fetchedAt), false, nil
}
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
return nil, fmtNullTime(fetchedAt), false, err
}
return &snap, fmtNullTime(fetchedAt), true, nil
}
func nullableStringArg(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts) // hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite { func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil { if err := s.ensureInit(); err != nil {
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
) )
if hostFilter == "" { if hostFilter == "" {
rows, err = s.db.Query(` rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models FROM models
WHERE watching = true WHERE watching = true
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
`) `)
} else { } else {
rows, err = s.db.Query(` rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models FROM models
WHERE watching = true AND host = $1 WHERE watching = true AND host = $1
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
createdAt, updatedAt time.Time createdAt, updatedAt time.Time
) )
err := s.db.QueryRow(` // q1: mit optionaler Spalte cb_online_last_error
q1 := `
SELECT SELECT
input,is_url,host,path,model_key, COALESCE(input,'') as input,
tags, last_stream, is_url,
last_seen_online, last_seen_online_at, COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
@ -1222,19 +1455,62 @@ SELECT
created_at, updated_at created_at, updated_at
FROM models FROM models
WHERE id=$1; WHERE id=$1;
`, id).Scan( `
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, // q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
&lastSeenOnline, &lastSeenOnlineAt, // Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, q2 := `
&watching, &favorite, &hot, &keep, &liked, SELECT
&createdAt, &updatedAt, COALESCE(input,'') as input,
) is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
'' as cb_online_last_error,
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
WHERE id=$1;
`
scan := func(q string) error {
return s.db.QueryRow(q, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
}
err := scan(q1)
if err != nil { if err != nil {
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden") return StoredModel{}, errors.New("model nicht gefunden")
} }
return StoredModel{}, err
// Fallback versuchen (typisch: "column cb_online_last_error does not exist")
err2 := scan(q2)
if err2 != nil {
// wenn fallback auch kein Row findet, sauber melden
if errors.Is(err2, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
// sonst ursprünglichen Fehler behalten? -> ich gebe hier err2 zurück, weil er meist aussagekräftiger ist.
return StoredModel{}, err2
}
} }
m := StoredModel{ m := StoredModel{
@ -1249,6 +1525,10 @@ WHERE id=$1;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, Hot: hot,

View File

@ -181,7 +181,7 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
// ✅ wenn der Job nie sichtbar war, nicht unnötig UI refreshen // ✅ wenn der Job nie sichtbar war, nicht unnötig UI refreshen
if wasVisible { if wasVisible {
notifyJobsChanged() notifyJobRemoved(jobID)
} }
} }

Binary file not shown.

261
backend/performance.go Normal file
View File

@ -0,0 +1,261 @@
// backend/performance.go
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"math"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
)
var serverStartedAt = time.Now()
var lastCPUUsageBits uint64 // atomic float64 bits
func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) }
func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) }
func buildPerfSnapshot() map[string]any {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
s := getSettings()
recordDir, _ := resolvePathRelativeToApp(s.RecordDir)
var diskFreeBytes uint64
var diskTotalBytes uint64
var diskUsedPercent float64
diskPath := recordDir
if recordDir != "" {
if u, err := godisk.Usage(recordDir); err == nil && u != nil {
diskFreeBytes = u.Free
diskTotalBytes = u.Total
diskUsedPercent = u.UsedPercent
}
}
// ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB)
pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds()
resp := map[string]any{
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx)
"uptimeSec": time.Since(serverStartedAt).Seconds(),
"cpuPercent": func() float64 {
v := getLastCPUUsage()
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 {
return 0
}
if v == 0 {
// Fallback: direkt messen, falls Controller nicht läuft / noch nicht gemessen hat
if p, err := gocpu.Percent(0, false); err == nil && len(p) > 0 {
return p[0]
}
}
return v
}(),
"diskPath": diskPath,
"diskFreeBytes": diskFreeBytes,
"diskTotalBytes": diskTotalBytes,
"diskUsedPercent": diskUsedPercent,
"diskEmergency": atomic.LoadInt32(&diskEmergency) == 1,
// ✅ statt LowDiskPauseBelowGB aus Settings
"diskPauseBelowGB": pauseGB,
"diskResumeAboveGB": resumeGB,
// ✅ optional, aber sehr hilfreich (Debug/UI)
"diskInFlightBytes": inFlight,
"diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)),
"diskPauseNeedBytes": pauseNeed,
"diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)),
"diskResumeNeedBytes": resumeNeed,
"diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)),
"goroutines": runtime.NumGoroutine(),
"mem": map[string]any{
"alloc": ms.Alloc,
"heapAlloc": ms.HeapAlloc,
"heapInuse": ms.HeapInuse,
"sys": ms.Sys,
"numGC": ms.NumGC,
},
}
sem := map[string]any{}
if genSem != nil {
sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()}
}
if previewSem != nil {
sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()}
}
if thumbSem != nil {
sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()}
}
if durSem != nil {
sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()}
}
if len(sem) > 0 {
resp["sem"] = sem
}
return resp
}
func pingHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func perfHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
resp := buildPerfSnapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(resp)
}
func perfStreamHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
// Optional: client kann Intervall mitgeben: /api/stream?ms=5000
ms := 5000
if q := r.URL.Query().Get("ms"); q != "" {
if v, err := strconv.Atoi(q); err == nil {
// clamp: 1000..30000
if v < 1000 {
v = 1000
}
if v > 30000 {
v = 30000
}
ms = v
}
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// hilfreich hinter nginx/proxies:
w.Header().Set("X-Accel-Buffering", "no")
ctx := r.Context()
// sofort erstes Event schicken
send := func() error {
payload := buildPerfSnapshot()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(payload); err != nil {
return err
}
// event: perf
_, _ = io.WriteString(w, "event: perf\n")
_, _ = io.WriteString(w, "data: ")
_, _ = w.Write(buf.Bytes())
_, _ = io.WriteString(w, "\n")
fl.Flush()
return nil
}
// initial
_ = send()
t := time.NewTicker(time.Duration(ms) * time.Millisecond)
hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts
defer t.Stop()
defer hb.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = send()
case <-hb.C:
// SSE Kommentar als Heartbeat
_, _ = io.WriteString(w, ": keep-alive\n\n")
fl.Flush()
}
}
}
func startAdaptiveSemController(ctx context.Context) {
targetHi := 85.0
targetLo := 65.0
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetHi = f
}
}
if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
targetLo = f
}
}
// Warmup (erste Messung kann 0 sein)
_, _ = gocpu.Percent(200*time.Millisecond, false)
t := time.NewTicker(2 * time.Second)
go func() {
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
p, err := gocpu.Percent(0, false)
if err != nil || len(p) == 0 {
continue
}
usage := p[0]
setLastCPUUsage(usage)
// Preview ist am teuersten → konservativ
if usage > targetHi {
previewSem.SetMax(previewSem.Max() - 1)
genSem.SetMax(genSem.Max() - 1)
thumbSem.SetMax(thumbSem.Max() - 1)
} else if usage < targetLo {
previewSem.SetMax(previewSem.Max() + 1)
genSem.SetMax(genSem.Max() + 1)
thumbSem.SetMax(thumbSem.Max() + 1)
}
}
}
}()
}

View File

@ -201,7 +201,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
} }
// global (oder in deinem app struct halten) // global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 4 var postWorkQ = NewPostWorkQueue(512, 1) // maxParallelFFmpeg = 1
// --- Status Refresher (ehemals postwork_refresh.go) --- // --- Status Refresher (ehemals postwork_refresh.go) ---
@ -211,10 +211,14 @@ func startPostWorkStatusRefresher() {
defer t.Stop() defer t.Stop()
for range t.C { for range t.C {
changed := false changedIDs := make([]string, 0, 16)
jobsMu.Lock() jobsMu.Lock()
for _, job := range jobs { for _, job := range jobs {
if job == nil {
continue
}
key := strings.TrimSpace(job.PostWorkKey) key := strings.TrimSpace(job.PostWorkKey)
if key == "" { if key == "" {
continue continue
@ -222,17 +226,19 @@ func startPostWorkStatusRefresher() {
st := postWorkQ.StatusForKey(key) st := postWorkQ.StatusForKey(key)
// ✅ Kein Typname nötig: job.PostWork ist *<StatusType>, st ist <StatusType>
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) { if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) {
tmp := st tmp := st
job.PostWork = &tmp job.PostWork = &tmp
changed = true
if strings.TrimSpace(job.ID) != "" {
changedIDs = append(changedIDs, job.ID)
}
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if changed { for _, id := range changedIDs {
notifyJobsChanged() notifyJobPatched(id)
} }
} }
}() }()

View File

@ -1652,6 +1652,17 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
return return
} }
_ = atomicWriteFile(thumbPath, img) _ = atomicWriteFile(thumbPath, img)
now := time.Now().UnixMilli()
jobsMu.Lock()
job.PreviewTick = now
last := job.LastPreviewPushMs
if now-last >= 900 {
job.LastPreviewPushMs = now
jobsMu.Unlock()
} else {
jobsMu.Unlock()
}
} }
func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) { func startLiveThumbWebPLoop(ctx context.Context, job *RecordJob) {

View File

@ -16,7 +16,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
@ -511,51 +510,6 @@ func recordJobs(w http.ResponseWriter, r *http.Request) {
respondJSON(w, list) respondJSON(w, list)
} }
// SSE (done stream)
func handleDoneStream(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ch := make(chan []byte, 32)
doneHub.add(ch)
defer doneHub.remove(ch)
fmt.Fprintf(w, ": hello seq=%d ts=%d\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli())
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprintf(w, ": ping ts=%d\n\n", time.Now().UnixMilli())
flusher.Flush()
case b, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "event: doneChanged\n")
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
}
}
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) { func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) { if !mustMethod(w, r, http.MethodPost) {
return return
@ -750,7 +704,6 @@ type doneIndexItem struct {
type doneIndexCache struct { type doneIndexCache struct {
mu sync.Mutex mu sync.Mutex
builtAt time.Time builtAt time.Time
seq uint64
doneAbs string doneAbs string
items []doneIndexItem items []doneIndexItem
@ -759,6 +712,15 @@ type doneIndexCache struct {
var doneCache doneIndexCache var doneCache doneIndexCache
func invalidateDoneCache() {
doneCache.mu.Lock()
doneCache.builtAt = time.Time{}
doneCache.doneAbs = ""
doneCache.items = nil
doneCache.sortedIdx = nil
doneCache.mu.Unlock()
}
func normalizeQueryModel(raw string) string { func normalizeQueryModel(raw string) string {
s := strings.TrimSpace(raw) s := strings.TrimSpace(raw)
if s == "" { if s == "" {
@ -1138,12 +1100,10 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
return return
} }
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now() now := time.Now()
doneCache.mu.Lock() doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq || needRebuild := doneCache.doneAbs != doneAbs ||
doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild { if needRebuild {
@ -1160,14 +1120,12 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
doneCache.sortedIdx["0|"+m] = []int{} doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{} doneCache.sortedIdx["1|"+m] = []int{}
} }
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs doneCache.doneAbs = doneAbs
doneCache.builtAt = now doneCache.builtAt = now
} else { } else {
items, sorted := buildDoneIndex(doneAbs) items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items doneCache.items = items
doneCache.sortedIdx = sorted doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs doneCache.doneAbs = doneAbs
doneCache.builtAt = now doneCache.builtAt = now
} }
@ -1198,6 +1156,78 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
respondJSON(w, doneMetaResp{Count: count}) respondJSON(w, doneMetaResp{Count: count})
} }
// getDoneCountFast nutzt dieselbe Cache/Index-Logik wie recordDoneMeta (Count-Mode),
// aber ohne http.ResponseWriter/Request.
// includeKeep=false, model="" entspricht deinem Badge im Tab.
func getDoneCountFast() int {
// gleiche Defaults wie Frontend-Badge: includeKeep=false, model=""
includeKeep := false
qModel := "" // kein model-filter
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
return 0
}
now := time.Now()
doneCache.mu.Lock()
needRebuild := doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild {
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
doneCache.items = nil
doneCache.sortedIdx = make(map[string][]int, 16)
modes := []string{
"completed_desc", "completed_asc",
"file_asc", "file_desc",
"duration_asc", "duration_desc",
"size_asc", "size_desc",
}
for _, m := range modes {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
}
items := doneCache.items
sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock()
// Count berechnen (wie in recordDoneMeta)
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
return len(sortedAll[incKey+"|completed_desc"])
}
// (optional) model filter hier aktuell nicht gebraucht
cnt := 0
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
cnt++
}
}
return cnt
}
func recordDoneList(w http.ResponseWriter, r *http.Request) { func recordDoneList(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) { if !mustMethod(w, r, http.MethodGet) {
return return
@ -1342,12 +1372,10 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return return
} }
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now() now := time.Now()
doneCache.mu.Lock() doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq || needRebuild := doneCache.doneAbs != doneAbs ||
doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild { if needRebuild {
@ -1364,14 +1392,12 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
doneCache.sortedIdx["0|"+m] = []int{} doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{} doneCache.sortedIdx["1|"+m] = []int{}
} }
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs doneCache.doneAbs = doneAbs
doneCache.builtAt = now doneCache.builtAt = now
} else { } else {
items, sorted := buildDoneIndex(doneAbs) items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items doneCache.items = items
doneCache.sortedIdx = sorted doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs doneCache.doneAbs = doneAbs
doneCache.builtAt = now doneCache.builtAt = now
} }
@ -1642,8 +1668,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
purgeDurationCacheForPath(target) purgeDurationCacheForPath(target)
removeJobsByOutputBasename(file) removeJobsByOutputBasename(file)
invalidateDoneCache()
notifyDoneChanged() notifyDoneChanged()
notifyJobsChanged() notifyDoneMetaChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
@ -1771,7 +1798,9 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
purgeDurationCacheForPath(src) purgeDurationCacheForPath(src)
purgeDurationCacheForPath(dst) purgeDurationCacheForPath(dst)
invalidateDoneCache()
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
@ -1841,7 +1870,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
invalidateDoneCache()
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
@ -1946,7 +1977,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return return
} }
invalidateDoneCache()
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
@ -2024,8 +2057,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
renameJobsOutputBasename(file, newFile) renameJobsOutputBasename(file, newFile)
invalidateDoneCache()
notifyDoneChanged() notifyDoneChanged()
notifyJobsChanged() notifyDoneMetaChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,

View File

@ -76,15 +76,18 @@ func RecordStream(
} }
} }
// 4) Datei öffnen // ✅ direkt in die finale Datei schreiben (kein .part)
file, err := os.Create(outputPath) tmpPath := outputPath
// best-effort: alte Datei löschen, damit wir sauber neu anfangen
_ = os.Remove(tmpPath)
// Datei öffnen (Create => truncate)
file, err := os.Create(tmpPath)
if err != nil { if err != nil {
return fmt.Errorf("datei erstellen: %w", err) return fmt.Errorf("datei erstellen: %w", err)
} }
defer func() { _ = file.Close() }()
defer func() {
_ = file.Close()
}()
// live size tracking (für UI) // live size tracking (für UI)
var written int64 var written int64
@ -112,9 +115,15 @@ func RecordStream(
now := time.Now() now := time.Now()
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 { if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
jobsMu.Lock() jobsMu.Lock()
changed := job.SizeBytes != written
job.SizeBytes = written job.SizeBytes = written
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
lastPush = now lastPush = now
lastBytes = written lastBytes = written
@ -125,10 +134,38 @@ func RecordStream(
_ = duration // aktuell unbenutzt _ = duration // aktuell unbenutzt
return nil return nil
}) })
if err != nil { if err != nil {
_ = file.Close()
_ = os.Remove(outputPath) // ✅ keine kaputte Enddatei liegen lassen
return fmt.Errorf("watch segments: %w", err) return fmt.Errorf("watch segments: %w", err)
} }
// ✅ wenn nie Bytes ankamen -> Enddatei löschen
if written <= 0 {
_ = file.Close()
_ = os.Remove(outputPath)
return fmt.Errorf("keine Segmente geschrieben (0 bytes) Stream vermutlich offline/blocked")
}
_ = file.Close()
if job != nil {
if fi, err := os.Stat(outputPath); err == nil && fi != nil && fi.Size() > 0 {
jobsMu.Lock()
changed := job.SizeBytes != fi.Size()
job.SizeBytes = fi.Size()
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
}
}
// ✅ kein Rename nötig wir haben schon in outputPath geschrieben
return nil return nil
} }
@ -244,6 +281,13 @@ func (p *Playlist) WatchSegments(
continue continue
} }
// ✅ NEU: HTTP Status prüfen (sonst liest du z.B. HTML Fehlerseiten / 403 etc.)
if segResp.StatusCode != 200 {
io.Copy(io.Discard, segResp.Body)
segResp.Body.Close()
continue
}
data, err := io.ReadAll(segResp.Body) data, err := io.ReadAll(segResp.Body)
segResp.Body.Close() segResp.Body.Close()
if err != nil || len(data) == 0 { if err != nil || len(data) == 0 {

View File

@ -354,6 +354,10 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
return errors.New("output file path leer") return errors.New("output file path leer")
} }
// ✅ direkt in outFile schreiben (kein .part)
tmpOut := outFile
_ = os.Remove(tmpOut) // best-effort sauber starten
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!) // ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
cmd := exec.CommandContext( cmd := exec.CommandContext(
ctx, ctx,
@ -364,7 +368,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
"-loglevel", "warning", "-loglevel", "warning",
"-i", m3u8URL, "-i", m3u8URL,
"-c", "copy", "-c", "copy",
outFile, tmpOut, // == outFile
) )
var stderr bytes.Buffer var stderr bytes.Buffer
@ -387,16 +391,23 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
case <-stopStat: case <-stopStat:
return return
case <-t.C: case <-t.C:
fi, err := os.Stat(outFile) fi, err := os.Stat(tmpOut)
if err != nil { if err != nil {
continue continue
} }
sz := fi.Size() sz := fi.Size()
if sz > 0 && sz != last { if sz > 0 && sz != last {
jobsMu.Lock() jobsMu.Lock()
changed := job.SizeBytes != sz
job.SizeBytes = sz job.SizeBytes = sz
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
last = sz last = sz
} }
} }
@ -410,6 +421,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
close(stopStat) close(stopStat)
if err != nil { if err != nil {
_ = os.Remove(outFile) // ✅ keine kaputte Enddatei liegen lassen
msg := strings.TrimSpace(stderr.String()) msg := strings.TrimSpace(stderr.String())
if msg != "" { if msg != "" {
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
@ -417,6 +429,23 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
return fmt.Errorf("ffmpeg m3u8 failed: %w", err) return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
} }
// ✅ Erfolg, aber trotzdem check: >0 bytes
if fi, serr := os.Stat(outFile); serr != nil || fi == nil || fi.Size() <= 0 {
_ = os.Remove(outFile)
return fmt.Errorf("ffmpeg lieferte leere Datei (0 bytes)")
} else if job != nil {
jobsMu.Lock()
changed := job.SizeBytes != fi.Size()
job.SizeBytes = fi.Size()
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
}
return nil return nil
} }

View File

@ -261,9 +261,12 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url { if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url {
if j.Hidden && !req.Hidden { if j.Hidden && !req.Hidden {
j.Hidden = false j.Hidden = false
jobID := strings.TrimSpace(j.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() if jobID != "" {
notifyJobPatched(jobID)
}
return j, nil return j, nil
} }
@ -314,7 +317,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
jobsMu.Unlock() jobsMu.Unlock()
if !job.Hidden { if !job.Hidden {
notifyJobsChanged() notifyJobPatched(job.ID)
} }
go runJob(ctx, job, req) go runJob(ctx, job, req)
@ -346,7 +349,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
setJobProgress(job, "recording", 0) setJobProgress(job, "recording", 0)
notifyJobsChanged() notifyJobPatched(job.ID)
switch provider { switch provider {
case "chaturbate": case "chaturbate":
@ -379,7 +382,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} }
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
@ -400,7 +403,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
err = RecordStreamMFC(ctx, hc, username, outPath, job) err = RecordStreamMFC(ctx, hc, username, outPath, job)
@ -436,7 +439,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.Phase = "postwork" job.Phase = "postwork"
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
if out == "" { if out == "" {
jobsMu.Lock() jobsMu.Lock()
@ -446,8 +449,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
return return
} }
@ -461,7 +465,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = fi.Size() job.SizeBytes = fi.Size()
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
if fi.Size() > 0 && fi.Size() < threshold { if fi.Size() > 0 && fi.Size() < threshold {
base := filepath.Base(out) base := filepath.Base(out)
@ -475,8 +479,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobRemoved(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
fmt.Println("🧹 auto-deleted (pre-queue):", base, "(size: "+formatBytesSI(fi.Size())+")") fmt.Println("🧹 auto-deleted (pre-queue):", base, "(size: "+formatBytesSI(fi.Size())+")")
@ -503,7 +508,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = &s job.PostWork = &s
} }
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
okQueued := postWorkQ.Enqueue(PostWorkTask{ okQueued := postWorkQ.Enqueue(PostWorkTask{
Key: postKey, Key: postKey,
@ -516,7 +521,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
setJobProgress(job, "postwork", 0) setJobProgress(job, "postwork", 0)
notifyJobsChanged() notifyJobPatched(job.ID)
} }
out := strings.TrimSpace(postOut) out := strings.TrimSpace(postOut)
@ -528,8 +533,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
return nil return nil
} }
@ -539,7 +545,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} }
// 1) Remux // 1) Remux
@ -550,7 +556,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} }
} }
@ -561,8 +567,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
} }
// 3) Duration // 3) Duration
@ -573,7 +580,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.DurationSeconds = sec job.DurationSeconds = sec
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} }
cancel() cancel()
} }
@ -590,7 +597,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.VideoHeight = h job.VideoHeight = h
job.FPS = fps job.FPS = fps
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} }
} }
@ -643,8 +650,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
return nil return nil
}, },
}) })
@ -654,7 +662,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
} else { } else {
jobsMu.Lock() jobsMu.Lock()
job.Status = postTarget job.Status = postTarget
@ -663,7 +671,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() notifyJobPatched(job.ID)
notifyDoneChanged() notifyDoneChanged()
notifyDoneMetaChanged()
} }
} }

View File

@ -85,6 +85,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
fmt.Println("⚠️ models load:", err) fmt.Println("⚠️ models load:", err)
} }
// ✅ für SSE modelsMetaSnapshotJSON()
modelStore = store
setCoverModelStore(store) setCoverModelStore(store)
RegisterModelAPI(api, store) RegisterModelAPI(api, store)
setChaturbateOnlineModelStore(store) setChaturbateOnlineModelStore(store)
@ -137,10 +140,13 @@ func buildPostgresDSNFromSettings() (string, error) {
if u.User != nil { if u.User != nil {
if pw, hasPw := u.User.Password(); hasPw { if pw, hasPw := u.User.Password(); hasPw {
pw = strings.TrimSpace(pw) pw = strings.TrimSpace(pw)
if pw != "" && pw != "****" {
// ✅ Placeholder/Masked password niemals als echtes Passwort verwenden
if pw == "" || pw == "****" || pw == "%2A%2A%2A%2A" {
// ignore -> unten EncryptedDBPassword nutzen
} else {
return u.String(), nil return u.String(), nil
} }
// sonst: Placeholder -> ignorieren und unten aus EncryptedDBPassword einsetzen
} }
} }
@ -183,8 +189,14 @@ func sanitizeDSNForLog(dsn string) string {
if err != nil { if err != nil {
return "<invalid dsn>" return "<invalid dsn>"
} }
if u.User != nil { if u.User != nil {
u.User = url.UserPassword(u.User.Username(), "****") user := u.User.Username()
if pw, hasPw := u.User.Password(); hasPw && strings.TrimSpace(pw) != "" {
u.User = url.UserPassword(user, "****")
} else {
u.User = url.User(user) // ✅ kein Passwort vorhanden -> keins loggen
}
} }
return u.String() return u.String()
} }

View File

@ -33,6 +33,8 @@ func main() {
store := registerRoutes(mux, auth) store := registerRoutes(mux, auth)
initSSE()
go startChaturbateOnlinePoller(store) go startChaturbateOnlinePoller(store)
go startChaturbateAutoStartWorker(store) go startChaturbateAutoStartWorker(store)
go startMyFreeCamsAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store)

View File

@ -1,151 +1,66 @@
// backend/sse.go // backend\sse.go
package main package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"sort"
"sync" "sync"
"sync/atomic"
"time" "time"
) )
// -------------------- SSE primitives -------------------- // -------------------- SSE primitives --------------------
type sseEvent struct {
name string
data []byte
}
type sseHub struct { type sseHub struct {
mu sync.Mutex mu sync.Mutex
clients map[chan []byte]struct{} clients map[chan sseEvent]struct{}
} }
func newSSEHub() *sseHub { func newSSEHub() *sseHub {
return &sseHub{clients: map[chan []byte]struct{}{}} return &sseHub{clients: map[chan sseEvent]struct{}{}}
} }
func (h *sseHub) add(ch chan []byte) { func (h *sseHub) add(ch chan sseEvent) {
h.mu.Lock() h.mu.Lock()
h.clients[ch] = struct{}{} h.clients[ch] = struct{}{}
h.mu.Unlock() h.mu.Unlock()
} }
func (h *sseHub) remove(ch chan []byte) { func (h *sseHub) remove(ch chan sseEvent) {
h.mu.Lock() h.mu.Lock()
delete(h.clients, ch) delete(h.clients, ch)
h.mu.Unlock() h.mu.Unlock()
close(ch) close(ch)
} }
func (h *sseHub) broadcast(b []byte) { func (h *sseHub) broadcastEvent(name string, b []byte) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
ev := sseEvent{name: name, data: b}
for ch := range h.clients { for ch := range h.clients {
// Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein)
select { select {
case ch <- b: case ch <- ev:
default: default:
} }
} }
} }
// -------------------- SSE channels + notify -------------------- // -------------------- only perf SSE --------------------
var ( var (
// done changed stream (Client soll nur "refetch done" machen)
doneHub = newSSEHub()
doneNotify = make(chan struct{}, 1)
doneSeq uint64
// record jobs stream
recordJobsHub = newSSEHub()
recordJobsNotify = make(chan struct{}, 1)
// assets task stream
assetsHub = newSSEHub()
assetsNotify = make(chan struct{}, 1)
// perf stream (periodic snapshot)
perfHub = newSSEHub() perfHub = newSSEHub()
) )
func notifyDoneChanged() { // initSSE startet nur noch den perf broadcaster.
select {
case doneNotify <- struct{}{}:
default:
}
}
func notifyJobsChanged() {
select {
case recordJobsNotify <- struct{}{}:
default:
}
}
func notifyAssetsChanged() {
select {
case assetsNotify <- struct{}{}:
default:
}
}
// initSSE startet die Debounce-/Ticker-Broadcaster.
// Wichtig: wird aus main.go init() aufgerufen.
func initSSE() { func initSSE() {
// Debounced broadcaster (jobs)
go func() {
for range recordJobsNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-recordJobsNotify:
default:
goto SEND
}
}
SEND:
recordJobsHub.broadcast(jobsSnapshotJSON())
}
}()
// Debounced broadcaster (done changed)
go func() {
for range doneNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-doneNotify:
default:
goto SEND
}
}
SEND:
seq := atomic.AddUint64(&doneSeq, 1)
b := []byte(fmt.Sprintf(`{"type":"doneChanged","seq":%d,"ts":%d}`, seq, time.Now().UnixMilli()))
doneHub.broadcast(b)
}
}()
// Debounced broadcaster (assets task)
go func() {
for range assetsNotify {
time.Sleep(80 * time.Millisecond)
for {
select {
case <-assetsNotify:
default:
goto SEND
}
}
SEND:
b := assetsSnapshotJSON()
if len(b) > 0 {
assetsHub.broadcast(b)
}
}
}()
// Periodic broadcaster (perf)
go func() { go func() {
t := time.NewTicker(3 * time.Second) t := time.NewTicker(3 * time.Second)
defer t.Stop() defer t.Stop()
@ -153,75 +68,23 @@ func initSSE() {
for range t.C { for range t.C {
b := perfSnapshotJSON() b := perfSnapshotJSON()
if len(b) > 0 { if len(b) > 0 {
perfHub.broadcast(b) perfHub.broadcastEvent("perf", b)
} }
} }
}() }()
} }
// -------------------- Snapshots --------------------
// jobsSnapshotJSON liefert die aktuelle (gefilterte) Job-Liste als JSON.
// Greift auf jobs/jobsMu aus main.go zu (gleiches Package).
func jobsSnapshotJSON() []byte {
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
// Hidden-Jobs niemals an die UI senden
if j == nil || j.Hidden {
continue
}
c := *j
c.cancel = nil // nicht serialisieren
list = append(list, &c)
}
jobsMu.Unlock()
// neueste zuerst
sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt)
})
b, _ := json.Marshal(list)
return b
}
func assetsSnapshotJSON() []byte {
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
b, _ := json.Marshal(st)
return b
}
// perfSnapshotJSON liefert einen Snapshot für das Frontend (PerformanceMonitor). // perfSnapshotJSON liefert einen Snapshot für das Frontend (PerformanceMonitor).
//
// ✅ WICHTIG: Hier musst du die bestehende Logik aus deinem perfStreamHandler
// (CPU%, Disk-Free/Total/Used%, serverMs) in eine gemeinsame Funktion ziehen.
// Diese Stub-Version kompiliert, liefert aber nur serverMs (Rest null).
func perfSnapshotJSON() []byte { func perfSnapshotJSON() []byte {
payload := map[string]any{ payload := buildPerfSnapshot()
"cpuPercent": nil,
"diskFreeBytes": nil,
"diskTotalBytes": nil,
"diskUsedPercent": nil,
"serverMs": time.Now().UnixMilli(), // Frontend: ping = Date.now() - serverMs
}
b, _ := json.Marshal(payload) b, _ := json.Marshal(payload)
return b return b
} }
// -------------------- SSE: /api/stream (UNIFIED) -------------------- // -------------------- SSE: /api/stream --------------------
// //
// Ein Stream für: // events:
// - event: jobs -> []RecordJob // - perf -> PerfSnapshot
// - event: doneChanged-> {"type":"doneChanged","seq":...,"ts":...}
// - event: state -> assetsTaskState
// - event: perf -> PerfSnapshot
//
// Frontend soll nur noch /api/stream öffnen (sseSingleton deduped per URL).
func appStream(w http.ResponseWriter, r *http.Request) { func appStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
@ -234,14 +97,12 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return return
} }
// SSE-Header
h := w.Header() h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8") h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform") h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive") h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no") h.Set("X-Accel-Buffering", "no")
// sofort starten
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
writeEvent := func(event string, data []byte) bool { writeEvent := func(event string, data []byte) bool {
@ -255,7 +116,7 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return false return false
} }
} else { } else {
if _, err := io.WriteString(w, "\n"); err != nil { if _, err := fmt.Fprint(w, "\n"); err != nil {
return false return false
} }
} }
@ -271,75 +132,22 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return true return true
} }
// Reconnect-Hinweis
if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil { if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil {
return return
} }
flusher.Flush() flusher.Flush()
// pro Client: je Hub ein Channel perfCh := make(chan sseEvent, 32)
jobsCh := make(chan []byte, 32)
doneCh := make(chan []byte, 32)
assetsCh := make(chan []byte, 32)
perfCh := make(chan []byte, 32)
recordJobsHub.add(jobsCh)
defer recordJobsHub.remove(jobsCh)
doneHub.add(doneCh)
defer doneHub.remove(doneCh)
assetsHub.add(assetsCh)
defer assetsHub.remove(assetsCh)
perfHub.add(perfCh) perfHub.add(perfCh)
defer perfHub.remove(perfCh) defer perfHub.remove(perfCh)
// Initial Snapshots // optional: direkt initialen perf snapshot schicken
if b := jobsSnapshotJSON(); len(b) > 0 {
if !writeEvent("jobs", b) {
return
}
}
// done: initialer "kick" (hilft, UI sofort zu syncen)
seq := atomic.LoadUint64(&doneSeq)
initDone := []byte(fmt.Sprintf(`{"type":"doneChanged","seq":%d,"ts":%d}`, seq, time.Now().UnixMilli()))
if !writeEvent("doneChanged", initDone) {
return
}
if b := assetsSnapshotJSON(); len(b) > 0 {
if !writeEvent("state", b) {
return
}
}
if b := perfSnapshotJSON(); len(b) > 0 { if b := perfSnapshotJSON(); len(b) > 0 {
if !writeEvent("perf", b) { if !writeEvent("perf", b) {
return return
} }
} }
// coalesce helper: wenn Burst, nur latest senden
drainLatest := func(first []byte, ch <-chan []byte) []byte {
last := first
for i := 0; i < 64; i++ {
select {
case nb, ok := <-ch:
if !ok {
return last
}
if len(nb) > 0 {
last = nb
}
default:
return last
}
}
return last
}
ctx := r.Context() ctx := r.Context()
ping := time.NewTicker(15 * time.Second) ping := time.NewTicker(15 * time.Second)
defer ping.Stop() defer ping.Stop()
@ -349,51 +157,20 @@ func appStream(w http.ResponseWriter, r *http.Request) {
case <-ctx.Done(): case <-ctx.Done():
return return
case b, ok := <-jobsCh: case ev, ok := <-perfCh:
if !ok { if !ok {
return return
} }
if len(b) == 0 { if len(ev.data) == 0 {
continue continue
} }
last := drainLatest(b, jobsCh)
if !writeEvent("jobs", last) {
return
}
case b, ok := <-doneCh: eventName := ev.name
if !ok { if eventName == "" {
return eventName = "perf"
}
if len(b) == 0 {
continue
}
last := drainLatest(b, doneCh)
if !writeEvent("doneChanged", last) {
return
} }
case b, ok := <-assetsCh: if !writeEvent(eventName, ev.data) {
if !ok {
return
}
if len(b) == 0 {
continue
}
last := drainLatest(b, assetsCh)
if !writeEvent("state", last) {
return
}
case b, ok := <-perfCh:
if !ok {
return
}
if len(b) == 0 {
continue
}
last := drainLatest(b, perfCh)
if !writeEvent("perf", last) {
return return
} }

View File

@ -62,6 +62,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return return
case http.MethodPost: case http.MethodPost:
// 1) Einmal locken, starten oder bestehenden State zurückgeben
assetsTaskMu.Lock() assetsTaskMu.Lock()
if assetsTaskState.Running { if assetsTaskState.Running {
st := assetsTaskState st := assetsTaskState
@ -70,28 +71,28 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return return
} }
// cancelbarer Context (pro Run) // 2) cancelbarer Context (pro Run)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
assetsTaskMu.Lock()
assetsTaskCancel = cancel assetsTaskCancel = cancel
assetsTaskMu.Unlock()
now := time.Now() now := time.Now()
st := updateAssetsState(func(st *AssetsTaskState) { assetsTaskState = AssetsTaskState{
*st = AssetsTaskState{ Running: true,
Running: true, Total: 0,
Total: 0, Done: 0,
Done: 0, GeneratedThumbs: 0,
GeneratedThumbs: 0, GeneratedPreviews: 0,
GeneratedPreviews: 0, Skipped: 0,
Skipped: 0, StartedAt: now,
StartedAt: now, FinishedAt: nil,
FinishedAt: nil, Error: "",
Error: "", CurrentFile: "Scanne…", // optional aber sehr hilfreich fürs “Startgefühl”
CurrentFile: "", }
} st := assetsTaskState
}) assetsTaskMu.Unlock()
// 3) SSE push (debounced broadcaster sendet Snapshot)
notifyAssetsChanged()
go runGenerateMissingAssets(ctx) go runGenerateMissingAssets(ctx)
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-BC3HxqFv.js"></script> <script type="module" crossorigin src="/assets/index-Czg-nDKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-e_Qq8t1c.css"> <link rel="stylesheet" crossorigin href="/assets/index-nA-1muWw.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,15 @@ type Props = {
jobs: RecordJob[] jobs: RecordJob[]
pending?: PendingWatchedRoom[] pending?: PendingWatchedRoom[]
modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }> modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
roomStatusByModelKey?: Record<
string,
{
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
>
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void onStopJob: (id: string) => void
blurPreviews?: boolean blurPreviews?: boolean
@ -289,6 +298,7 @@ function DownloadsCardRow({
nowMs, nowMs,
blurPreviews, blurPreviews,
modelsByKey, modelsByKey,
roomStatusByModelKey,
stopRequestedIds, stopRequestedIds,
postworkInfoOf, postworkInfoOf,
markStopRequested, markStopRequested,
@ -302,6 +312,15 @@ function DownloadsCardRow({
nowMs: number nowMs: number
blurPreviews?: boolean blurPreviews?: boolean
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }> modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
roomStatusByModelKey: Record<
string,
{
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
>
stopRequestedIds: Record<string, true> stopRequestedIds: Record<string, true>
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
markStopRequested: (ids: string | string[]) => void markStopRequested: (ids: string | string[]) => void
@ -439,8 +458,11 @@ function DownloadsCardRow({
Boolean(phase) && Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100) (!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : '' const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined const flags = key ? modelsByKey[key] : undefined
const room = key ? roomStatusByModelKey[key] : undefined
const roomShow = normalizeRoomShow(room?.current_show)
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
@ -481,12 +503,7 @@ function DownloadsCardRow({
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
blur={blurPreviews} blur={blurPreviews}
alignStartAt={j.startedAt} thumbTick={Number((j as any).previewTick ?? 0)}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)} thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
@ -503,13 +520,12 @@ function DownloadsCardRow({
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
'bg-gray-900/5 text-gray-800 dark:bg-white/10 dark:text-gray-200', roomBadgeClass(roomShow),
isStopping ? 'ring-1 ring-amber-500/30' : 'ring-1 ring-emerald-500/25',
].join(' ')} ].join(' ')}
title={statusText} title={roomShow}
> >
{statusText} {roomShow}
</span> </span>
</div> </div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}> <div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
@ -624,6 +640,48 @@ const modelNameFromOutput = (output?: string) => {
return i > 0 ? stem.slice(0, i) : stem return i > 0 ? stem.slice(0, i) : stem
} }
const normalizeRoomShow = (v?: string | null): string => {
const s = String(v ?? '').trim().toLowerCase()
if (!s) return 'unknown'
if (s === 'public' || s === 'private' || s === 'hidden' || s === 'away' || s === 'offline') return s
return 'unknown'
}
const roomBadgeClass = (show: string): string => {
switch (show) {
case 'public':
return 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
case 'private':
return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
case 'hidden':
return 'bg-violet-500/15 text-violet-900 ring-violet-500/30 dark:bg-violet-400/10 dark:text-violet-200 dark:ring-violet-400/25'
case 'away':
return 'bg-sky-500/15 text-sky-900 ring-sky-500/30 dark:bg-sky-400/10 dark:text-sky-200 dark:ring-sky-400/25'
case 'offline':
return 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
const modelKeyFromJob = (job: RecordJob): string => {
const fromOutput = modelNameFromOutput(job.output || '').trim().toLowerCase()
if (fromOutput && fromOutput !== '—') return fromOutput
const src = String((job as any)?.sourceUrl ?? '').trim().toLowerCase()
if (src) {
try {
const u = new URL(src)
const parts = u.pathname.split('/').filter(Boolean)
if (parts[0]) return decodeURIComponent(parts[0]).trim().toLowerCase()
} catch {
// ignore
}
}
return ''
}
const formatDuration = (ms: number): string => { const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '—' if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000) const total = Math.floor(ms / 1000)
@ -732,6 +790,7 @@ export default function Downloads({
onToggleWatch, onToggleWatch,
onAddToDownloads, onAddToDownloads,
modelsByKey = {}, modelsByKey = {},
roomStatusByModelKey = {},
blurPreviews blurPreviews
}: Props) { }: Props) {
@ -996,12 +1055,7 @@ export default function Downloads({
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
blur={blurPreviews} blur={blurPreviews}
alignStartAt={j.startedAt} thumbTick={Number((j as any).previewTick ?? 0)}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsWebpCandidates(j)} thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
@ -1051,21 +1105,9 @@ export default function Downloads({
const f = baseName(j.output || '') const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output) const name = modelNameFromOutput(j.output)
const rawStatus = String(j.status ?? '').toLowerCase() const key = modelKeyFromJob(j)
const room = key ? roomStatusByModelKey[key] : undefined
// Final "stopped" sauber erkennen (inkl. UI-Stop) const roomShow = normalizeRoomShow(room?.current_show)
const isStopRequested = Boolean(stopRequestedIds[j.id])
const stopInitiated = Boolean(stopInitiatedIds[j.id])
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
// ✅ Status-Text neben dem Modelname: NUR Job-Status
// (keine phase wie assets/moving/remuxing)
const statusText = isStoppedFinal ? 'stopped' : (rawStatus || 'unknown')
// Optional: "Stoppe…" rein UI-seitig anzeigen, aber ohne phase
const showStoppingUI = !isStoppedFinal && isStopRequested
const badgeText = showStoppingUI ? 'stopping' : statusText
return ( return (
<> <>
@ -1073,24 +1115,15 @@ export default function Downloads({
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}> <span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name} {name}
</span> </span>
{ /* Status-Badge */}
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
rawStatus === 'running' roomBadgeClass(roomShow),
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: isStoppedFinal
? 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
: rawStatus === 'failed'
? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25'
: rawStatus === 'finished'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: showStoppingUI
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')} ].join(' ')}
title={badgeText} title={roomShow}
> >
{badgeText} {roomShow}
</span> </span>
</div> </div>
<span className="block max-w-[220px] truncate" title={j.output}> <span className="block max-w-[220px] truncate" title={j.output}>
@ -1218,8 +1251,8 @@ export default function Downloads({
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const key = modelNameFromOutput(j.output || '') const key = modelKeyFromJob(j)
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined const flags = key ? modelsByKey[key] : undefined
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
@ -1271,7 +1304,7 @@ export default function Downloads({
}, },
}, },
] ]
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf]) }, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
const downloadJobRows = useMemo<DownloadRow[]>(() => { const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobsLive const list = jobsLive
@ -1450,6 +1483,7 @@ export default function Downloads({
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
@ -1475,6 +1509,7 @@ export default function Downloads({
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
@ -1500,6 +1535,7 @@ export default function Downloads({
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}

View File

@ -71,6 +71,9 @@ type ModalProps = {
/** Optional: kleines Bild im mobilen collapsed Header */ /** Optional: kleines Bild im mobilen collapsed Header */
mobileCollapsedImageSrc?: string mobileCollapsedImageSrc?: string
mobileCollapsedImageAlt?: string mobileCollapsedImageAlt?: string
/** Optional: Actions rechts neben dem Title (Desktop Header + Mobile App-Bar) */
titleRight?: ReactNode
} }
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
@ -98,6 +101,7 @@ export default function Modal({
rightBodyClassName, rightBodyClassName,
mobileCollapsedImageSrc, mobileCollapsedImageSrc,
mobileCollapsedImageAlt, mobileCollapsedImageAlt,
titleRight,
}: ModalProps) { }: ModalProps) {
// sensible defaults // sensible defaults
const scrollMode: ModalScroll = const scrollMode: ModalScroll =
@ -220,6 +224,12 @@ export default function Modal({
) : null} ) : null}
</div> </div>
{titleRight ? (
<div className="shrink-0 flex items-center gap-2">
{titleRight}
</div>
) : null}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@ -314,20 +324,24 @@ export default function Modal({
</div> </div>
</div> </div>
<button <div className="shrink-0 flex items-center gap-2">
type="button" {titleRight ? <div className="flex items-center">{titleRight}</div> : null}
onClick={onClose}
className={cn( <button
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5', type="button"
'text-gray-500 hover:text-gray-900 hover:bg-black/5', onClick={onClose}
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600', className={cn(
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500' 'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
)} 'text-gray-500 hover:text-gray-900 hover:bg-black/5',
aria-label="Schließen" 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
title="Schließen" 'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
> )}
<XMarkIcon className="size-5" /> aria-label="Schließen"
</button> title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div>
</div> </div>
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */} {/* Sticky tabs/actions (always sticky because in this sticky wrapper) */}

View File

@ -323,6 +323,7 @@ function chooseSpriteGrid(count: number): [number, number] {
type ChaturbateRoom = { type ChaturbateRoom = {
gender?: string gender?: string
location?: string location?: string
country?: string
current_show?: string current_show?: string
username?: string username?: string
room_subject?: string room_subject?: string
@ -434,6 +435,9 @@ type StoredModel = {
keep?: boolean keep?: boolean
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
cbOnlineJson?: string | null
cbOnlineFetchedAt?: string | null
cbOnlineLastError?: string | null
} }
type Props = { type Props = {
@ -527,7 +531,6 @@ export default function ModelDetails({
//const isDesktop = useMediaQuery('(min-width: 640px)') //const isDesktop = useMediaQuery('(min-width: 640px)')
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = React.useState<StoredModel[]>([])
const [, setModelsLoading] = React.useState(false)
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null) const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null) const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
@ -544,7 +547,7 @@ export default function ModelDetails({
const runningReqSeqRef = React.useRef(0) const runningReqSeqRef = React.useRef(0)
const [bioRefreshSeq, setBioRefreshSeq] = React.useState(0) const [, setBioRefreshSeq] = React.useState(0)
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null) const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
@ -559,6 +562,12 @@ export default function ModelDetails({
const key = normalizeModelKey(modelKey) const key = normalizeModelKey(modelKey)
type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info')
const bioReqRef = React.useRef<AbortController | null>(null)
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) ===== // ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
const [durations, setDurations] = React.useState<Record<string, number>>({}) const [durations, setDurations] = React.useState<Record<string, number>>({})
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null) const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
@ -613,6 +622,54 @@ export default function ModelDetails({
setStopPending(false) setStopPending(false)
}, [open, key]) }, [open, key])
const refreshBio = React.useCallback(async () => {
if (!key) return
// vorherigen abbrechen
bioReqRef.current?.abort()
const ac = new AbortController()
bioReqRef.current = ac
setBioLoading(true)
try {
const cookieHeader = buildChaturbateCookieHeader(cookies)
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
const r = await fetch(url, {
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
})
if (!r.ok) {
const text = await r.text().catch(() => '')
throw new Error(text || `HTTP ${r.status}`)
}
const data = (await r.json().catch(() => null)) as BioResp
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const nextBio = (data?.bio as BioContext) ?? null
setBioMeta(meta)
setBio(nextBio)
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
mdBioMem.set(key, entry)
ssSet(ssKeyBio(key), entry)
} catch (e: any) {
if (e?.name === 'AbortError') return
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
} finally {
setBioLoading(false)
}
}, [key, cookies])
React.useEffect(() => {
if (open) return
bioReqRef.current?.abort()
bioReqRef.current = null
}, [open])
const refetchModels = React.useCallback(async () => { const refetchModels = React.useCallback(async () => {
try { try {
const r = await fetch('/api/models', { cache: 'no-store' }) const r = await fetch('/api/models', { cache: 'no-store' })
@ -647,6 +704,35 @@ export default function ModelDetails({
} }
}, [key, donePage]) }, [key, donePage])
const refetchDoneRef = React.useRef(refetchDone)
React.useEffect(() => {
refetchDoneRef.current = refetchDone
}, [refetchDone])
React.useEffect(() => {
if (!open) return
const es = new EventSource('/api/stream')
const onJobs = () => {
// optional
}
const onDone = () => {
void refetchDoneRef.current()
}
es.addEventListener('jobs', onJobs)
es.addEventListener('doneChanged', onDone)
return () => {
es.removeEventListener('jobs', onJobs)
es.removeEventListener('doneChanged', onDone)
es.close()
}
}, [open])
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht // erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
function jobFromModelKey(key: string): RecordJob { function jobFromModelKey(key: string): RecordJob {
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext // muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
@ -670,148 +756,17 @@ export default function ModelDetails({
const runningList = React.useMemo(() => { const runningList = React.useMemo(() => {
return Array.isArray(runningJobs) ? runningJobs : running return Array.isArray(runningJobs) ? runningJobs : running
}, [runningJobs, running]) }, [runningJobs, running])
React.useEffect(() => { React.useEffect(() => {
if (!open) return if (!open) return
setDonePage(1) setDonePage(1)
}, [open, modelKey]) }, [open, modelKey])
// Models list (local flags + stored tags)
React.useEffect(() => { React.useEffect(() => {
if (!open) return if (!open) return
let alive = true void refetchModels()
setModelsLoading(true) }, [open, refetchModels])
fetch('/api/models', { cache: 'no-store' })
.then((r) => r.json())
.then((data: StoredModel[]) => {
if (!alive) return
setModels(Array.isArray(data) ? data : [])
})
.catch(() => {
if (!alive) return
setModels([])
})
.finally(() => {
if (!alive) return
setModelsLoading(false)
})
return () => {
alive = false
}
}, [open])
// ✅ Online: nur einmalig laden (kein Polling)
React.useEffect(() => {
if (!open || !key) return
// wenn wir frische Daten aus Cache haben -> keinen Request
const mem = mdOnlineMem.get(key)
const ss = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
const hit =
(mem && isFresh(mem.at) ? mem : null) ||
(ss && isFresh(ss.at) ? ss : null)
if (hit) return
let alive = true
const ac = new AbortController()
;(async () => {
try {
const cookieHeader = buildChaturbateCookieHeader(cookies)
const r = await fetch('/api/chaturbate/online', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : {}),
},
cache: 'no-store',
signal: ac.signal,
body: JSON.stringify({ q: [key], show: [], refresh: false }),
})
const data = (await r.json().catch(() => null)) as OnlineResp
if (!alive) return
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
const nextRoom = rooms[0] ?? null
setRoomMeta(meta)
setRoom(nextRoom)
const entry: OnlineCacheEntry = { at: Date.now(), room: nextRoom, meta }
mdOnlineMem.set(key, entry)
ssSet(ssKeyOnline(key), entry)
} catch (e: any) {
if (e?.name === 'AbortError') return
if (!alive) return
setRoomMeta({ enabled: undefined, fetchedAt: undefined, lastError: 'Fetch fehlgeschlagen' })
}
})()
return () => {
alive = false
ac.abort()
}
}, [open, key, cookies])
// ✅ NEW: BioContext (proxy)
React.useEffect(() => {
if (!open || !key) return
let alive = true
setBioLoading(true)
setBio(null)
setBioMeta(null)
const cookieHeader = buildChaturbateCookieHeader(cookies)
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}${
bioRefreshSeq > 0 ? '&refresh=1' : ''
}`
fetch(url, {
cache: 'no-store',
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
})
.then(async (r) => {
if (!r.ok) {
const text = await r.text().catch(() => '')
throw new Error(text || `HTTP ${r.status}`)
}
return r.json()
})
.then((data: BioResp) => {
if (!alive) return
setBioMeta({ enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError })
setBio((data?.bio as BioContext) ?? null)
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const nextBio = (data?.bio as BioContext) ?? null
setBioMeta(meta)
setBio(nextBio)
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
mdBioMem.set(key, entry)
ssSet(ssKeyBio(key), entry)
})
.catch((e) => {
if (!alive) return
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
})
.finally(() => {
if (!alive) return
setBioLoading(false)
})
return () => {
alive = false
}
}, [open, key, bioRefreshSeq, cookies])
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden // Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
React.useEffect(() => { React.useEffect(() => {
@ -857,6 +812,26 @@ export default function ModelDetails({
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key]) }, [models, key])
const storedRoomFromSnap = React.useMemo<ChaturbateRoom | null>(() => {
const raw = (model as any)?.cbOnlineJson
if (!raw || typeof raw !== 'string') return null
try {
return JSON.parse(raw) as ChaturbateRoom
} catch {
return null
}
}, [model])
const storedRoomMeta = React.useMemo(() => {
const fetchedAt = (model as any)?.cbOnlineFetchedAt
const lastError = (model as any)?.cbOnlineLastError
if (!fetchedAt && !lastError) return null
return { enabled: true, fetchedAt, lastError } as Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'>
}, [model])
const effectiveRoom = room ?? storedRoomFromSnap
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
const doneMatches = done const doneMatches = done
const runningMatches = React.useMemo(() => { const runningMatches = React.useMemo(() => {
@ -867,24 +842,12 @@ export default function ModelDetails({
}) })
}, [runningList, key]) }, [runningList, key])
const allTags = React.useMemo(() => { const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
const a = splitTags(model?.tags) const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
const b = Array.isArray(room?.tags) ? room!.tags : [] const heroImgFull = effectiveRoom?.image_url || heroImg
const map = new Map<string, string>() const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
for (const t of [...a, ...b]) {
const k = String(t).trim().toLowerCase()
if (!k) continue
if (!map.has(k)) map.set(k, String(t).trim())
}
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
}, [model?.tags, room?.tags])
const titleName = room?.display_name || model?.modelKey || key || 'Model' const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
const heroImg = room?.image_url_360x270 || room?.image_url || ''
const heroImgFull = room?.image_url || heroImg
const roomUrl = room?.chat_room_url_revshare || room?.chat_room_url || ''
const showLabel = (room?.current_show || '').trim().toLowerCase()
const showPill = showLabel const showPill = showLabel
? showLabel === 'public' ? showLabel === 'public'
? 'Public' ? 'Public'
@ -910,6 +873,18 @@ export default function ModelDetails({
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : [] const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : [] const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
const allTags = React.useMemo(() => {
const a = splitTags(model?.tags)
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
const map = new Map<string, string>()
for (const t of [...a, ...b]) {
const k = String(t).trim().toLowerCase()
if (!k) continue
if (!map.has(k)) map.set(k, String(t).trim())
}
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
}, [model?.tags, effectiveRoom?.tags])
const Stat = ({ const Stat = ({
icon, icon,
label, label,
@ -1113,9 +1088,6 @@ export default function ModelDetails({
[setScrubIndexForKey] [setScrubIndexForKey]
) )
type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info')
React.useEffect(() => { React.useEffect(() => {
if (!open) return if (!open) return
setTab('info') setTab('info')
@ -1139,6 +1111,22 @@ export default function ModelDetails({
mobileCollapsedImageSrc={heroImg || undefined} mobileCollapsedImageSrc={heroImg || undefined}
mobileCollapsedImageAlt={titleName} mobileCollapsedImageAlt={titleName}
rightBodyClassName="pt-0 sm:pt-2" rightBodyClassName="pt-0 sm:pt-2"
titleRight={
tab === 'info' ? (
<Button
variant="secondary"
className={cn('h-8 px-2 sm:h-9 sm:px-3', 'whitespace-nowrap')}
disabled={bioLoading || !modelKey}
onClick={() => void refreshBio()}
title="BioContext neu abrufen"
>
<span className="inline-flex items-center gap-2">
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
<span className="hidden sm:inline">Bio aktualisieren</span>
</span>
</Button>
) : null
}
left={ left={
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
@ -1195,7 +1183,7 @@ export default function ModelDetails({
</span> </span>
) : null} ) : null}
{room?.is_hd ? ( {effectiveRoom?.is_hd ? (
<span <span
className={pill( className={pill(
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 backdrop-blur dark:text-indigo-200 dark:ring-indigo-400/20' 'bg-indigo-500/10 text-indigo-900 ring-indigo-200 backdrop-blur dark:text-indigo-200 dark:ring-indigo-400/20'
@ -1205,7 +1193,7 @@ export default function ModelDetails({
</span> </span>
) : null} ) : null}
{room?.is_new ? ( {effectiveRoom?.is_new ? (
<span <span
className={pill( className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20' 'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
@ -1219,10 +1207,10 @@ export default function ModelDetails({
{/* Title */} {/* Title */}
<div className="absolute bottom-3 left-3 right-3"> <div className="absolute bottom-3 left-3 right-3">
<div className="truncate text-sm font-semibold text-white drop-shadow"> <div className="truncate text-sm font-semibold text-white drop-shadow">
{room?.display_name || room?.username || model?.modelKey || key || '—'} {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
</div> </div>
<div className="truncate text-xs text-white/85 drop-shadow"> <div className="truncate text-xs text-white/85 drop-shadow">
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''} {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
</div> </div>
</div> </div>
@ -1338,11 +1326,11 @@ export default function ModelDetails({
{/* Summary */} {/* Summary */}
<div className="p-3 sm:p-4"> <div className="p-3 sm:p-4">
<div className="grid grid-cols-2 gap-2 sm:gap-3"> <div className="grid grid-cols-2 gap-2 sm:gap-3">
<Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(room?.num_users)} /> <Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(effectiveRoom?.num_users)} />
<Stat <Stat
icon={<SparklesIcon className="size-4" />} icon={<SparklesIcon className="size-4" />}
label="Follower" label="Follower"
value={fmtInt(room?.num_followers ?? bioFollowers)} value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
/> />
</div> </div>
@ -1353,7 +1341,7 @@ export default function ModelDetails({
Location Location
</dt> </dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words"> <dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{room?.location || bioLocation || '—'} {effectiveRoom?.location || bioLocation || '—'}
</dd> </dd>
</div> </div>
@ -1363,7 +1351,7 @@ export default function ModelDetails({
Sprache Sprache
</dt> </dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words"> <dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{room?.spoken_languages || '—'} {effectiveRoom?.spoken_languages || '—'}
</dd> </dd>
</div> </div>
@ -1373,7 +1361,7 @@ export default function ModelDetails({
Online Online
</dt> </dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words"> <dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{fmtHms(room?.seconds_online)} {fmtHms(effectiveRoom?.seconds_online)}
</dd> </dd>
</div> </div>
@ -1383,7 +1371,7 @@ export default function ModelDetails({
Alter Alter
</dt> </dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words"> <dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{bioAge != null ? String(bioAge) : room?.age != null ? String(room.age) : '—'} {bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
</dd> </dd>
</div> </div>
@ -1399,20 +1387,20 @@ export default function ModelDetails({
</dl> </dl>
{/* Meta warnings */} {/* Meta warnings */}
{roomMeta?.enabled === false ? ( {effectiveRoomMeta?.enabled === false ? (
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200"> <div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
Chaturbate-Online ist aktuell deaktiviert. Chaturbate-Online ist aktuell deaktiviert.
</div> </div>
) : roomMeta?.lastError ? ( ) : effectiveRoomMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200"> <div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
<div className="font-medium">Online-Info: {errorSummary(roomMeta.lastError)}</div> <div className="font-medium">Online-Info: {errorSummary(effectiveRoomMeta.lastError)}</div>
<details className="mt-1"> <details className="mt-1">
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80"> <summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80">
Details Details
</summary> </summary>
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10"> <pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10">
{errorDetails(roomMeta.lastError)} {errorDetails(effectiveRoomMeta.lastError)}
</pre> </pre>
</details> </details>
</div> </div>
@ -1457,283 +1445,211 @@ export default function ModelDetails({
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */} {/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
{/* ===================== */} {/* ===================== */}
<div className="sm:hidden px-2 pb-2 space-y-1.5"> <div className="sm:hidden px-2 pb-2 space-y-1.5">
{/* Header Card (dein bisheriger Mobile-Block) */} {/* HERO Header (mobile) */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-2.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5"> <div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 overflow-hidden">
<div className="flex items-start gap-3"> {/* Hero Background */}
{/* Avatar */} <div className="relative h-40">
<button {heroImg ? (
type="button" <button
className="relative shrink-0 overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-white/10" type="button"
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)} className="absolute inset-0 block w-full"
aria-label="Bild vergrößern" onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
> aria-label="Bild vergrößern"
{heroImg ? ( >
<img <img
src={heroImg} src={heroImgFull || heroImg}
alt={titleName} alt={titleName}
className={cn('size-10 object-cover', previewBlurCls(blurPreviews))} className={cn('h-full w-full object-cover', previewBlurCls(blurPreviews))}
/> />
) : ( </button>
<div className="size-10 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" /> ) : (
)} <div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
)}
{/* Status dot */} {/* Gradient overlay */}
<span <div
aria-hidden aria-hidden
className={cn( className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/0"
'absolute bottom-1.5 right-1.5 size-2.5 rounded-full ring-2 ring-white/80 dark:ring-gray-900/60', />
(effectivePresenceLabel || '').toLowerCase() === 'online' ? 'bg-emerald-400' : 'bg-gray-400'
)}
/>
</button>
{/* Name + actions (right) + pills */} {/* Top row: name + action icons */}
<div className="min-w-0 flex-1"> <div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
<div className="flex items-start justify-between gap-2"> <div className="min-w-0">
{/* Name */} <div className="truncate text-base font-semibold text-white drop-shadow">
<div className="min-w-0"> {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{room?.display_name || room?.username || model?.modelKey || key || '—'}
</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
</div>
</div> </div>
<div className="truncate text-xs text-white/85 drop-shadow">
{/* ✅ Buttons rechts neben Name */} {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
<div className="shrink-0 flex items-center gap-1.5">
{/* Watched */}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleWatchModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.watching
? 'bg-sky-500/15 ring-sky-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
)}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
>
<span className="relative inline-block size-4">
<EyeOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<EyeSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-sky-500'
)}
/>
</span>
</button>
{/* Favorite */}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleFavoriteModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.favorite
? 'bg-amber-500/15 ring-amber-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
)}
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={Boolean(model?.favorite)}
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
>
<span className="relative inline-block size-4">
<StarOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<StarSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-amber-500'
)}
/>
</span>
</button>
{/* Like */}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleLikeModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.liked
? 'bg-rose-500/15 ring-rose-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
)}
title={model?.liked ? 'Like entfernen' : 'Liken'}
aria-pressed={model?.liked === true}
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
>
<span className="relative inline-block size-4">
<HeartOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<HeartSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-rose-500'
)}
/>
</span>
</button>
</div> </div>
</div> </div>
{/* Pills (jetzt unter der Namenszeile) */} <div className="shrink-0 flex items-center gap-1.5">
<div className="mt-1 flex flex-wrap items-center gap-1.5"> {/* Watched */}
{showPill ? ( <button
<span type="button"
className={pill( onClick={(e) => {
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20' e.preventDefault()
)} e.stopPropagation()
> handleToggleWatchModel()
{showPill} }}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
>
<span className="relative inline-block size-4">
<EyeOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<EyeSolidIcon className={cn('absolute inset-0 size-4', model?.watching ? 'opacity-100' : 'opacity-0', 'text-sky-200')} />
</span> </span>
) : null} </button>
{bioStatus ? ( {/* Favorite */}
<span <button
className={pill( type="button"
bioStatus.toLowerCase() === 'online' onClick={(e) => {
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20' e.preventDefault()
: 'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15' e.stopPropagation()
)} handleToggleFavoriteModel()
> }}
{bioStatus} className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={Boolean(model?.favorite)}
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
>
<span className="relative inline-block size-4">
<StarOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<StarSolidIcon className={cn('absolute inset-0 size-4', model?.favorite ? 'opacity-100' : 'opacity-0', 'text-amber-200')} />
</span> </span>
) : null} </button>
{room?.is_hd ? ( {/* Like */}
<span <button
className={pill( type="button"
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20' onClick={(e) => {
)} e.preventDefault()
> e.stopPropagation()
HD handleToggleLikeModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.liked ? 'Like entfernen' : 'Liken'}
aria-pressed={model?.liked === true}
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
>
<span className="relative inline-block size-4">
<HeartOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<HeartSolidIcon className={cn('absolute inset-0 size-4', model?.liked ? 'opacity-100' : 'opacity-0', 'text-rose-200')} />
</span> </span>
) : null} </button>
{room?.is_new ? (
<span
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:text-amber-200 dark:ring-amber-400/20'
)}
>
NEW
</span>
) : null}
</div> </div>
</div>
{/* Row unten: nur Room-Link (optional) */} {/* Pills bottom-left */}
{roomUrl ? ( <div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
<div className="mt-1.5 flex justify-end"> {showPill ? (
<a <span className={pill('bg-white/15 text-white ring-white/20')}>
href={roomUrl} {showPill}
target="_blank" </span>
rel="noreferrer"
className={cn(
'inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium',
'border border-gray-200/70 bg-white/70 text-gray-900 shadow-sm backdrop-blur hover:bg-white',
'dark:border-white/10 dark:bg-white/5 dark:text-white'
)}
title="Room öffnen"
>
<ArrowTopRightOnSquareIcon className="size-4" />
<span>Room</span>
</a>
</div>
) : null} ) : null}
</div>
</div>
{/* Quick stats (compact row) */} {effectivePresenceLabel ? (
<div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[12px] text-gray-700 dark:text-gray-200"> <span
<span className="inline-flex items-center gap-1"> className={pill(
<UsersIcon className="size-3.5 text-gray-400" /> (effectivePresenceLabel || '').toLowerCase() === 'online'
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_users)}</span> ? 'bg-emerald-500/25 text-white ring-emerald-200/30'
</span> : 'bg-white/15 text-white ring-white/20'
<span className="inline-flex items-center gap-1"> )}
<SparklesIcon className="size-3.5 text-gray-400" /> >
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_followers ?? bioFollowers)}</span> {effectivePresenceLabel}
</span> </span>
<span className="inline-flex items-center gap-1"> ) : null}
<ClockIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(room?.seconds_online)}</span>
</span>
<span className="inline-flex items-center gap-1">
<CalendarDaysIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">
{bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'}
</span>
</span>
</div>
{/* Meta warnings (mobile) */} {effectiveRoom?.is_hd ? <span className={pill('bg-white/15 text-white ring-white/20')}>HD</span> : null}
{roomMeta?.enabled === false ? ( {effectiveRoom?.is_new ? <span className={pill('bg-white/15 text-white ring-white/20')}>NEW</span> : null}
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
Chaturbate-Online ist aktuell deaktiviert.
</div> </div>
) : roomMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
Online-Info: {roomMeta.lastError}
</div>
) : null}
{bioMeta?.enabled === false ? ( {/* Room link bottom-right */}
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200"> {roomUrl ? (
BioContext ist aktuell deaktiviert. <div className="absolute right-3 bottom-3">
</div> <a
) : bioMeta?.lastError ? ( href={roomUrl}
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200"> target="_blank"
<div className="flex items-start justify-between gap-2"> rel="noreferrer"
<div className="font-medium">BioContext: {errorSummary(bioMeta.lastError)}</div> className="inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium bg-white/15 text-white ring-1 ring-white/20 backdrop-blur hover:bg-white/20"
title="Room öffnen"
>
<ArrowTopRightOnSquareIcon className="size-4" />
<span>Room</span>
</a>
</div> </div>
<details className="mt-1"> ) : null}
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80"> </div>
Details
</summary> {/* Quick stats row (unter dem Hero) */}
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10"> <div className="px-3 py-2 text-[12px] text-gray-700 dark:text-gray-200">
{errorDetails(bioMeta.lastError)} <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
</pre> <span className="inline-flex items-center gap-1">
</details> <UsersIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_users)}</span>
</span>
<span className="inline-flex items-center gap-1">
<SparklesIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}</span>
</span>
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(effectiveRoom?.seconds_online)}</span>
</span>
<span className="inline-flex items-center gap-1">
<CalendarDaysIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">
{bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'}
</span>
</span>
</div> </div>
) : null}
{/* Meta warnings (mobile) */}
{effectiveRoomMeta?.enabled === false ? (
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
Chaturbate-Online ist aktuell deaktiviert.
</div>
) : effectiveRoomMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
Online-Info: {effectiveRoomMeta.lastError}
</div>
) : null}
{bioMeta?.enabled === false ? (
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
BioContext ist aktuell deaktiviert.
</div>
) : bioMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
<div className="flex items-start justify-between gap-2">
<div className="font-medium">BioContext: {errorSummary(bioMeta.lastError)}</div>
</div>
<details className="mt-1">
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80">
Details
</summary>
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10">
{errorDetails(bioMeta.lastError)}
</pre>
</details>
</div>
) : null}
</div>
</div> </div>
{/* Tags (mobile, compact row) */} {/* Tags (mobile, compact row) */}
@ -1764,15 +1680,15 @@ export default function ModelDetails({
{/* ===================== */} {/* ===================== */}
{/* (dein bisheriger Header) Row 1: Meta + Actions */} {/* (dein bisheriger Header) Row 1: Meta + Actions */}
{/* ===================== */} {/* ===================== */}
<div className="flex items-start justify-between gap-2 px-2 py-2 sm:px-4"> <div className="hidden sm:flex items-start justify-between gap-2 px-2 py-2 sm:px-4">
{/* Meta */} {/* Meta */}
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300"> <div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
{key ? ( {key ? (
<div className="flex flex-wrap gap-x-2 gap-y-1"> <div className="flex flex-wrap gap-x-2 gap-y-1">
{roomMeta?.fetchedAt ? ( {effectiveRoomMeta?.fetchedAt ? (
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500 dark:text-gray-400">
Online-Stand: {fmtDateTime(roomMeta.fetchedAt)} Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
</span> </span>
) : null} ) : null}
{bioMeta?.fetchedAt ? ( {bioMeta?.fetchedAt ? (
@ -1802,21 +1718,6 @@ export default function ModelDetails({
{/* Actions */} {/* Actions */}
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{tab === 'info' ? (
<Button
variant="secondary"
className={cn('h-9 px-3 text-sm', 'whitespace-nowrap')}
disabled={bioLoading || !modelKey}
onClick={() => setBioRefreshSeq((x) => x + 1)}
title="BioContext neu abrufen"
>
<span className="inline-flex items-center gap-2">
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
<span className="hidden sm:inline">Bio aktualisieren</span>
</span>
</Button>
) : null}
{roomUrl ? ( {roomUrl ? (
<a <a
href={roomUrl} href={roomUrl}
@ -1872,8 +1773,8 @@ export default function ModelDetails({
<div className="text-sm font-semibold text-gray-900 dark:text-white">Room Subject</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Room Subject</div>
</div> </div>
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100"> <div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
{room?.room_subject ? ( {effectiveRoom?.room_subject ? (
<p className="line-clamp-4 whitespace-pre-wrap break-words">{room.room_subject}</p> <p className="line-clamp-4 whitespace-pre-wrap break-words">{effectiveRoom.room_subject}</p>
) : ( ) : (
<p className="text-gray-600 dark:text-gray-300">Keine Subject-Info vorhanden.</p> <p className="text-gray-600 dark:text-gray-300">Keine Subject-Info vorhanden.</p>
)} )}

View File

@ -9,19 +9,9 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = { type Props = {
jobId: string jobId: string
thumbTick?: number thumbTick?: number
autoTickMs?: number
blur?: boolean blur?: boolean
className?: string className?: string
fit?: 'cover' | 'contain' fit?: 'cover' | 'contain'
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
thumbsWebpUrl?: string | null thumbsWebpUrl?: string | null
thumbsCandidates?: Array<string | null | undefined> thumbsCandidates?: Array<string | null | undefined>
} }
@ -29,15 +19,8 @@ type Props = {
export default function ModelPreview({ export default function ModelPreview({
jobId, jobId,
thumbTick, thumbTick,
autoTickMs = 10_000,
blur = false, blur = false,
className, className,
alignStartAt,
alignEndAt = null,
alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
thumbsWebpUrl, thumbsWebpUrl,
thumbsCandidates, thumbsCandidates,
}: Props) { }: Props) {
@ -53,23 +36,10 @@ export default function ModelPreview({
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const inViewRef = useRef(false) const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false) const [directImgError, setDirectImgError] = useState(false)
const [apiImgError, setApiImgError] = useState(false) const [apiImgError, setApiImgError] = useState(false)
const retryT = useRef<number | null>(null) const [, setPageVisible] = useState(true)
const fastTries = useRef(0)
const hadSuccess = useRef(false)
const enteredViewOnce = useRef(false)
const [pageVisible, setPageVisible] = useState(true)
const toMs = (v: any): number => {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (v instanceof Date) return v.getTime()
const ms = Date.parse(String(v ?? ''))
return Number.isFinite(ms) ? ms : NaN
}
const normalizeUrl = (u?: string | null): string => { const normalizeUrl = (u?: string | null): string => {
const s = String(u ?? '').trim() const s = String(u ?? '').trim()
@ -106,12 +76,6 @@ export default function ModelPreview({
return () => document.removeEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis)
}, []) }, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel // ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
@ -136,69 +100,8 @@ export default function ModelPreview({
return () => obs.disconnect() return () => obs.disconnect()
}, []) }, [])
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView) return
if (!pageVisibleRef.current) return
if (enteredViewOnce.current) return
enteredViewOnce.current = true
setLocalTick((x) => x + 1)
}, [inView, thumbTick])
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
useEffect(() => {
if (typeof thumbTick === 'number') return
if (!inView) return
if (!pageVisibleRef.current) return
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
if (!Number.isFinite(period) || period <= 0) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
// aligned schedule
if (Number.isFinite(startMs)) {
let t: number | undefined
const schedule = () => {
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
if (!pageVisibleRef.current) return
const now = Date.now()
if (Number.isFinite(endMs) && now >= endMs) return
const elapsed = Math.max(0, now - startMs)
const rem = elapsed % period
const wait = rem === 0 ? period : period - rem
t = window.setTimeout(() => {
// ✅ nochmal checken, falls inzwischen offscreen/hidden
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
schedule()
}, wait)
}
schedule()
return () => {
if (t) window.clearTimeout(t)
}
}
// fallback interval
const id = window.setInterval(() => {
if (!inViewRef.current) return
if (!pageVisibleRef.current) return
setLocalTick((x) => x + 1)
}, period)
return () => window.clearInterval(id)
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
// ✅ tick Quelle // ✅ tick Quelle
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick const rawTick = typeof thumbTick === 'number' ? thumbTick : 0
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!) // ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
// Wir "freezen" den Tick, solange inView=false oder tab hidden // Wir "freezen" den Tick, solange inView=false oder tab hidden
@ -212,28 +115,15 @@ export default function ModelPreview({
setFrozenTick(rawTick) setFrozenTick(rawTick)
}, [rawTick, inView]) }, [rawTick, inView])
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => { useEffect(() => {
setDirectImgError(false) setDirectImgError(false)
setApiImgError(false) setApiImgError(false)
}, [frozenTick]) }, [frozenTick, jobId, thumbsCandidatesKey])
// bei Job-Wechsel reset
useEffect(() => {
hadSuccess.current = false
fastTries.current = 0
enteredViewOnce.current = false
setDirectImgError(false)
setApiImgError(false)
if (inViewRef.current && pageVisibleRef.current) {
setLocalTick((x) => x + 1)
}
}, [jobId, thumbsCandidatesKey])
const v = frozenTick || 1
const thumb = useMemo( const thumb = useMemo(
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`, () => `/api/preview?id=${encodeURIComponent(jobId)}&v=${v}`,
[jobId, frozenTick] [jobId, v]
) )
const hq = useMemo( const hq = useMemo(
@ -251,10 +141,10 @@ export default function ModelPreview({
const currentImgSrc = useMemo(() => { const currentImgSrc = useMemo(() => {
if (useDirectThumb) { if (useDirectThumb) {
const sep = directThumb.includes('?') ? '&' : '?' const sep = directThumb.includes('?') ? '&' : '?'
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}` return `${directThumb}${sep}v=${encodeURIComponent(String(v))}`
} }
return thumb return thumb
}, [useDirectThumb, directThumb, frozenTick, thumb]) }, [useDirectThumb, directThumb, v, thumb])
return ( return (
<HoverPopover <HoverPopover
@ -311,42 +201,18 @@ export default function ModelPreview({
alt="" alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => { onLoad={() => {
hadSuccess.current = true
fastTries.current = 0
if (retryT.current) window.clearTimeout(retryT.current)
// nur den aktuell genutzten Pfad als "ok" markieren
if (useDirectThumb) setDirectImgError(false) if (useDirectThumb) setDirectImgError(false)
else setApiImgError(false) else setApiImgError(false)
}} }}
onError={() => { onError={() => {
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten // direct preview.webp kaputt -> auf /api/preview fallback
if (useDirectThumb) { if (useDirectThumb) {
setDirectImgError(true) setDirectImgError(true)
return return
} }
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik // API kaputt -> placeholder
setApiImgError(true) setApiImgError(true)
if (!fastRetryMs) return
if (!inViewRef.current || !pageVisibleRef.current) return
if (hadSuccess.current) return
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
const windowMs = Number(fastRetryWindowMs ?? 60_000)
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
if (!withinWindow) return
const max = Number(fastRetryMax ?? 25)
if (fastTries.current >= max) return
if (retryT.current) window.clearTimeout(retryT.current)
retryT.current = window.setTimeout(() => {
fastTries.current += 1
setApiImgError(false) // API erneut probieren
setLocalTick((x) => x + 1)
}, fastRetryMs)
}} }}
/> />
) : ( ) : (

View File

@ -442,8 +442,19 @@ export default function ModelsTab() {
setLoading(true) setLoading(true)
setErr(null) setErr(null)
try { try {
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' }) const res = await fetch('/api/models', { cache: 'no-store' as any })
setModels(Array.isArray(list) ? list : []) if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`))
const data = await res.json().catch(() => null)
// ✅ akzeptiere beide Formen: Array ODER { items: [...] }
const list: StoredModel[] = Array.isArray(data?.items)
? (data.items as StoredModel[])
: Array.isArray(data)
? (data as StoredModel[])
: []
setModels(list)
void refreshVideoCounts() void refreshVideoCounts()
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))

View File

@ -102,7 +102,7 @@ function useFps(sampleMs = 1000) {
export default function PerformanceMonitor({ export default function PerformanceMonitor({
mode = 'inline', mode = 'inline',
className, className,
pollMs = 3000, pollMs = 1000,
}: Props) { }: Props) {
const fps = useFps(1000) const fps = useFps(1000)

View File

@ -1743,10 +1743,7 @@ export default function Player({
const videoChrome = ( const videoChrome = (
<div <div
className={cn( className="relative overflow-visible flex-1 min-h-0 aspect-video"
'relative overflow-visible',
expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
)}
onMouseEnter={() => { onMouseEnter={() => {
if (!miniDesktop || !canHover) return if (!miniDesktop || !canHover) return
setChromeHover(true) setChromeHover(true)

View File

@ -422,12 +422,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
transition: animMs ? `transform ${animMs}ms ease` : undefined, transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined, willChange: dx !== 0 ? 'transform' : undefined,
/*
boxShadow: boxShadow:
dx !== 0 dx !== 0
? swipeDir === 'right' ? swipeDir === 'right'
? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})` ? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})`
: `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})` : `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})`
: undefined, : undefined,
*/
borderRadius: dx !== 0 ? '12px' : undefined, borderRadius: dx !== 0 ? '12px' : undefined,
filter: filter:
dx !== 0 dx !== 0

View File

@ -186,19 +186,21 @@ export default function Task({
const unsub = subscribeSSE<TaskState>(STREAM_URL, TASK_STATE_EVENT, (st) => { const unsub = subscribeSSE<TaskState>(STREAM_URL, TASK_STATE_EVENT, (st) => {
setState(st) setState(st)
if (st?.running) { const isRunning = Boolean(st?.running)
if (isRunning) {
const ac = ensureControllerCreated() const ac = ensureControllerCreated()
armTaskList(ac) armTaskList(ac)
onProgressRef.current?.({
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
} }
const errText = String(st?.error ?? '').trim() // ✅ Immer nach oben spiegeln (TaskList sieht sofort "Scanne…" / total/done)
onProgressRef.current?.({
done: st?.done ?? 0,
total: st?.total ?? 0,
currentFile: st?.currentFile ?? '',
})
// Abbruch ist kein "Fehler"-Event für die UI const errText = String(st?.error ?? '').trim()
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) { if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText lastErrorRef.current = errText
onErrorRef.current?.(errText) onErrorRef.current?.(errText)