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
}
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,
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
func startChaturbateOnlinePoller(store *ModelStore) {
@ -454,6 +525,45 @@ func startChaturbateOnlinePoller(store *ModelStore) {
_ = 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
if cbModelStore != nil && len(rooms) > 0 {
shouldFill := false
@ -602,7 +712,7 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
return
}
changedAny := false
changedIDs := make([]string, 0, 4)
jobsMu.Lock()
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
if old != "" && old != newHls {
stopPreview(j)
// PreviewState zurücksetzen (damit "private/offline" nicht hängen bleibt)
j.PreviewState = ""
j.PreviewStateAt = ""
j.PreviewStateMsg = ""
}
changedAny = true
if strings.TrimSpace(j.ID) != "" {
changedIDs = append(changedIDs, j.ID)
}
}
jobsMu.Unlock()
if changedAny {
notifyJobsChanged()
for _, id := range changedIDs {
notifyJobPatched(id)
}
}
@ -849,6 +960,18 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser
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)
// 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
// ---------------------------
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
if cbModelStore != nil && onlySpecificUsers && !fetchedAt.IsZero() {
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
for _, u := range users {
_, isOnline := liteByUser[u]
isOnline := false
if liteByUser != nil {
_, isOnline = liteByUser[u]
}
_ = 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))
if onlySpecificUsers && liteByUser != nil {
if onlySpecificUsers {
for _, u := range users {
rm, ok := liteByUser[u]
if !ok {
continue
var (
rm ChaturbateOnlineRoomLite
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) {
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{
Username: rm.Username,
CurrentShow: rm.CurrentShow,
ChatRoomURL: rm.ChatRoomURL,
ImageURL: rm.ImageURL,
Username: username,
CurrentShow: currentShow,
ChatRoomURL: chatRoomURL,
ImageURL: strings.TrimSpace(rm.ImageURL),
})
}
}

View File

@ -81,11 +81,13 @@ func stopJobsInternal(list []*RecordJob) {
}
type payload struct {
jobID string
cmd *exec.Cmd
cancel context.CancelFunc
}
pl := make([]payload, 0, len(list))
changedIDs := make([]string, 0, len(list))
jobsMu.Lock()
for _, job := range list {
@ -94,12 +96,25 @@ func stopJobsInternal(list []*RecordJob) {
}
job.Phase = "stopping"
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
if strings.TrimSpace(job.ID) != "" {
changedIDs = append(changedIDs, job.ID)
}
}
jobsMu.Unlock()
notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress)
// 1) UI sofort updaten
for _, id := range changedIDs {
notifyJobPatched(id)
}
for _, p := range pl {
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 {

View File

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

View File

@ -11,7 +11,6 @@ import (
"errors"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
@ -21,16 +20,16 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/grafov/m3u8"
gocpu "github.com/shirou/gopsutil/v3/cpu"
godisk "github.com/shirou/gopsutil/v3/disk"
)
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
// wird von SSE (modelsMetaSnapshotJSON) genutzt
var modelStore *ModelStore
type JobStatus string
const (
@ -76,9 +75,11 @@ type RecordJob struct {
previewWebpAt time.Time `json:"-"`
previewGen bool `json:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
PreviewUA string `json:"-"` // user-agent
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig)
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:"-"`
previewLastHit time.Time `json:"-"`
@ -213,13 +214,6 @@ var (
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() {
t := time.NewTicker(5 * time.Second)
go func() {
@ -257,8 +251,6 @@ func init() {
initFFmpegSemaphores()
startAdaptiveSemController(context.Background())
startPreviewIdleKiller()
initSSE()
}
func publishJob(jobID string) bool {
@ -271,7 +263,6 @@ func publishJob(jobID string) bool {
j.Hidden = false
jobsMu.Unlock()
notifyJobsChanged()
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 {
size int64
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})$`,
)
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) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
@ -784,6 +544,10 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
}
func setJobPhase(job *RecordJob, phase string, progress int) {
if job == nil {
return
}
if progress < 0 {
progress = 0
}
@ -795,9 +559,6 @@ func setJobPhase(job *RecordJob, phase string, progress int) {
job.Phase = phase
job.Progress = progress
jobsMu.Unlock()
notifyJobsChanged()
}
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
@ -1659,7 +1420,6 @@ func removeJobsByOutputBasename(file string) {
return
}
removed := false
jobsMu.Lock()
for id, j := range jobs {
if j == nil {
@ -1671,14 +1431,9 @@ func removeJobsByOutputBasename(file string) {
}
if filepath.Base(out) == file {
delete(jobs, id)
removed = true
}
}
jobsMu.Unlock()
if removed {
notifyJobsChanged()
}
}
func renameJobsOutputBasename(oldFile, newFile string) {
@ -1688,7 +1443,6 @@ func renameJobsOutputBasename(oldFile, newFile string) {
return
}
changed := false
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
@ -1700,14 +1454,9 @@ func renameJobsOutputBasename(oldFile, newFile string) {
}
if filepath.Base(out) == oldFile {
j.Output = filepath.Join(filepath.Dir(out), newFile)
changed = true
}
}
jobsMu.Unlock()
if changed {
notifyJobsChanged()
}
}
// 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)
if job.SizeBytes <= 0 {
job.SizeBytes = fi.Size()
notifyJobPatched(job.ID)
}
// Meta.json lesen/erzeugen (best effort)

View File

@ -29,14 +29,14 @@ type Model struct {
Liked *bool `json:"liked"` // nil = keine Angabe
}
type modelStore struct {
type jsonModelStore struct {
mu sync.Mutex
path string
loaded bool
items []Model
}
var models = &modelStore{
var models = &jsonModelStore{
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"})
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

View File

@ -3,7 +3,9 @@ package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
@ -25,6 +27,11 @@ type StoredModel struct {
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
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"`
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
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 = CASE
WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6
ELSE last_stream
WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
ELSE last_stream
END,
watching = CASE WHEN $7=true THEN true ELSE watching END,
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
hot = CASE WHEN $9=true THEN true ELSE hot 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
WHERE id = $13;
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
return []StoredModel{}
}
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'')
rows, err := s.db.Query(`
q1 := `
SELECT
id,input,is_url,host,path,model_key,
tags, last_stream,
last_seen_online, last_seen_online_at,
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,
COALESCE(cb_online_last_error,''), -- optional
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,
@ -830,9 +849,41 @@ SELECT
created_at, updated_at
FROM models
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 {
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()
@ -841,14 +892,17 @@ ORDER BY updated_at DESC;
for rows.Next() {
var (
id, input, host, path, modelKey, tags string
isURL bool
isURL bool
lastStream sql.NullTime
lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt sql.NullTime
hasProfileImage int64
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
&id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching,
Favorite: favorite,
Hot: hot,
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
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)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil {
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
)
if hostFilter == "" {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true
ORDER BY updated_at DESC;
`)
} else {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true AND host = $1
ORDER BY updated_at DESC;
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt sql.NullTime
hasProfileImage int64
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
createdAt, updatedAt time.Time
)
err := s.db.QueryRow(`
// q1: mit optionaler Spalte cb_online_last_error
q1 := `
SELECT
input,is_url,host,path,model_key,
tags, last_stream,
last_seen_online, last_seen_online_at,
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,
COALESCE(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,
@ -1222,19 +1455,62 @@ SELECT
created_at, updated_at
FROM models
WHERE id=$1;
`, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
`
// q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
// Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
q2 := `
SELECT
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 {
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
if errors.Is(err, sql.ErrNoRows) {
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{
@ -1249,6 +1525,10 @@ WHERE id=$1;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching,
Favorite: favorite,
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
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)
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 4
var postWorkQ = NewPostWorkQueue(512, 1) // maxParallelFFmpeg = 1
// --- Status Refresher (ehemals postwork_refresh.go) ---
@ -211,10 +211,14 @@ func startPostWorkStatusRefresher() {
defer t.Stop()
for range t.C {
changed := false
changedIDs := make([]string, 0, 16)
jobsMu.Lock()
for _, job := range jobs {
if job == nil {
continue
}
key := strings.TrimSpace(job.PostWorkKey)
if key == "" {
continue
@ -222,17 +226,19 @@ func startPostWorkStatusRefresher() {
st := postWorkQ.StatusForKey(key)
// ✅ Kein Typname nötig: job.PostWork ist *<StatusType>, st ist <StatusType>
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) {
tmp := st
job.PostWork = &tmp
changed = true
if strings.TrimSpace(job.ID) != "" {
changedIDs = append(changedIDs, job.ID)
}
}
}
jobsMu.Unlock()
if changed {
notifyJobsChanged()
for _, id := range changedIDs {
notifyJobPatched(id)
}
}
}()

View File

@ -1652,6 +1652,17 @@ func updateLiveThumbWebPOnce(ctx context.Context, job *RecordJob) {
return
}
_ = 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) {

View File

@ -16,7 +16,6 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
@ -511,51 +510,6 @@ func recordJobs(w http.ResponseWriter, r *http.Request) {
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) {
if !mustMethod(w, r, http.MethodPost) {
return
@ -750,7 +704,6 @@ type doneIndexItem struct {
type doneIndexCache struct {
mu sync.Mutex
builtAt time.Time
seq uint64
doneAbs string
items []doneIndexItem
@ -759,6 +712,15 @@ type doneIndexCache struct {
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 {
s := strings.TrimSpace(raw)
if s == "" {
@ -1138,12 +1100,10 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
return
}
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now()
doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq ||
doneCache.doneAbs != doneAbs ||
needRebuild := doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild {
@ -1160,14 +1120,12 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
@ -1198,6 +1156,78 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
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) {
if !mustMethod(w, r, http.MethodGet) {
return
@ -1342,12 +1372,10 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
return
}
curSeq := atomic.LoadUint64(&doneSeq)
now := time.Now()
doneCache.mu.Lock()
needRebuild := doneCache.seq != curSeq ||
doneCache.doneAbs != doneAbs ||
needRebuild := doneCache.doneAbs != doneAbs ||
now.Sub(doneCache.builtAt) > 30*time.Second
if needRebuild {
@ -1364,14 +1392,12 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
doneCache.sortedIdx["0|"+m] = []int{}
doneCache.sortedIdx["1|"+m] = []int{}
}
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.seq = curSeq
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
@ -1642,8 +1668,9 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
purgeDurationCacheForPath(target)
removeJobsByOutputBasename(file)
invalidateDoneCache()
notifyDoneChanged()
notifyJobsChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,
@ -1771,7 +1798,9 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
purgeDurationCacheForPath(src)
purgeDurationCacheForPath(dst)
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,
@ -1841,7 +1870,9 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,
@ -1946,7 +1977,9 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
return
}
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,
@ -2024,8 +2057,9 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
renameJobsOutputBasename(file, newFile)
invalidateDoneCache()
notifyDoneChanged()
notifyJobsChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,

View File

@ -76,15 +76,18 @@ func RecordStream(
}
}
// 4) Datei öffnen
file, err := os.Create(outputPath)
// ✅ direkt in die finale Datei schreiben (kein .part)
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 {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
defer func() { _ = file.Close() }()
// live size tracking (für UI)
var written int64
@ -112,9 +115,15 @@ func RecordStream(
now := time.Now()
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
jobsMu.Lock()
changed := job.SizeBytes != written
job.SizeBytes = written
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock()
notifyJobsChanged()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
lastPush = now
lastBytes = written
@ -125,10 +134,38 @@ func RecordStream(
_ = duration // aktuell unbenutzt
return nil
})
if err != nil {
_ = file.Close()
_ = os.Remove(outputPath) // ✅ keine kaputte Enddatei liegen lassen
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
}
@ -244,6 +281,13 @@ func (p *Playlist) WatchSegments(
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)
segResp.Body.Close()
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")
}
// ✅ direkt in outFile schreiben (kein .part)
tmpOut := outFile
_ = os.Remove(tmpOut) // best-effort sauber starten
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
cmd := exec.CommandContext(
ctx,
@ -364,7 +368,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
"-loglevel", "warning",
"-i", m3u8URL,
"-c", "copy",
outFile,
tmpOut, // == outFile
)
var stderr bytes.Buffer
@ -387,16 +391,23 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
case <-stopStat:
return
case <-t.C:
fi, err := os.Stat(outFile)
fi, err := os.Stat(tmpOut)
if err != nil {
continue
}
sz := fi.Size()
if sz > 0 && sz != last {
jobsMu.Lock()
changed := job.SizeBytes != sz
job.SizeBytes = sz
jobID := strings.TrimSpace(job.ID)
sizeBytes := job.SizeBytes
jobsMu.Unlock()
notifyJobsChanged()
if changed && jobID != "" {
notifyJobSizePatched(jobID, sizeBytes)
}
last = sz
}
}
@ -410,6 +421,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
close(stopStat)
if err != nil {
_ = os.Remove(outFile) // ✅ keine kaputte Enddatei liegen lassen
msg := strings.TrimSpace(stderr.String())
if 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)
}
// ✅ 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
}

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

View File

@ -85,6 +85,9 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
fmt.Println("⚠️ models load:", err)
}
// ✅ für SSE modelsMetaSnapshotJSON()
modelStore = store
setCoverModelStore(store)
RegisterModelAPI(api, store)
setChaturbateOnlineModelStore(store)
@ -137,10 +140,13 @@ func buildPostgresDSNFromSettings() (string, error) {
if u.User != nil {
if pw, hasPw := u.User.Password(); hasPw {
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
}
// sonst: Placeholder -> ignorieren und unten aus EncryptedDBPassword einsetzen
}
}
@ -183,8 +189,14 @@ func sanitizeDSNForLog(dsn string) string {
if err != nil {
return "<invalid dsn>"
}
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()
}

View File

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

View File

@ -1,151 +1,66 @@
// backend/sse.go
// backend\sse.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
// -------------------- SSE primitives --------------------
type sseEvent struct {
name string
data []byte
}
type sseHub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
clients map[chan sseEvent]struct{}
}
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.clients[ch] = struct{}{}
h.mu.Unlock()
}
func (h *sseHub) remove(ch chan []byte) {
func (h *sseHub) remove(ch chan sseEvent) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *sseHub) broadcast(b []byte) {
func (h *sseHub) broadcastEvent(name string, b []byte) {
h.mu.Lock()
defer h.mu.Unlock()
ev := sseEvent{name: name, data: b}
for ch := range h.clients {
// Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein)
select {
case ch <- b:
case ch <- ev:
default:
}
}
}
// -------------------- SSE channels + notify --------------------
// -------------------- only perf SSE --------------------
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()
)
func notifyDoneChanged() {
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.
// initSSE startet nur noch den perf broadcaster.
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() {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
@ -153,75 +68,23 @@ func initSSE() {
for range t.C {
b := perfSnapshotJSON()
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).
//
// ✅ 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 {
payload := map[string]any{
"cpuPercent": nil,
"diskFreeBytes": nil,
"diskTotalBytes": nil,
"diskUsedPercent": nil,
"serverMs": time.Now().UnixMilli(), // Frontend: ping = Date.now() - serverMs
}
payload := buildPerfSnapshot()
b, _ := json.Marshal(payload)
return b
}
// -------------------- SSE: /api/stream (UNIFIED) --------------------
// -------------------- SSE: /api/stream --------------------
//
// Ein Stream für:
// - event: jobs -> []RecordJob
// - event: doneChanged-> {"type":"doneChanged","seq":...,"ts":...}
// - event: state -> assetsTaskState
// - event: perf -> PerfSnapshot
//
// Frontend soll nur noch /api/stream öffnen (sseSingleton deduped per URL).
// events:
// - perf -> PerfSnapshot
func appStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
@ -234,14 +97,12 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return
}
// SSE-Header
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache, no-transform")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
// sofort starten
w.WriteHeader(http.StatusOK)
writeEvent := func(event string, data []byte) bool {
@ -255,7 +116,7 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return false
}
} else {
if _, err := io.WriteString(w, "\n"); err != nil {
if _, err := fmt.Fprint(w, "\n"); err != nil {
return false
}
}
@ -271,75 +132,22 @@ func appStream(w http.ResponseWriter, r *http.Request) {
return true
}
// Reconnect-Hinweis
if _, err := fmt.Fprintf(w, "retry: 3000\n\n"); err != nil {
return
}
flusher.Flush()
// pro Client: je Hub ein Channel
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)
perfCh := make(chan sseEvent, 32)
perfHub.add(perfCh)
defer perfHub.remove(perfCh)
// Initial Snapshots
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
}
}
// optional: direkt initialen perf snapshot schicken
if b := perfSnapshotJSON(); len(b) > 0 {
if !writeEvent("perf", b) {
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()
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
@ -349,51 +157,20 @@ func appStream(w http.ResponseWriter, r *http.Request) {
case <-ctx.Done():
return
case b, ok := <-jobsCh:
case ev, ok := <-perfCh:
if !ok {
return
}
if len(b) == 0 {
if len(ev.data) == 0 {
continue
}
last := drainLatest(b, jobsCh)
if !writeEvent("jobs", last) {
return
}
case b, ok := <-doneCh:
if !ok {
return
}
if len(b) == 0 {
continue
}
last := drainLatest(b, doneCh)
if !writeEvent("doneChanged", last) {
return
eventName := ev.name
if eventName == "" {
eventName = "perf"
}
case b, ok := <-assetsCh:
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) {
if !writeEvent(eventName, ev.data) {
return
}

View File

@ -62,6 +62,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return
case http.MethodPost:
// 1) Einmal locken, starten oder bestehenden State zurückgeben
assetsTaskMu.Lock()
if assetsTaskState.Running {
st := assetsTaskState
@ -70,28 +71,28 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
return
}
// cancelbarer Context (pro Run)
// 2) cancelbarer Context (pro Run)
ctx, cancel := context.WithCancel(context.Background())
assetsTaskMu.Lock()
assetsTaskCancel = cancel
assetsTaskMu.Unlock()
now := time.Now()
st := updateAssetsState(func(st *AssetsTaskState) {
*st = AssetsTaskState{
Running: true,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
StartedAt: now,
FinishedAt: nil,
Error: "",
CurrentFile: "",
}
})
assetsTaskState = AssetsTaskState{
Running: true,
Total: 0,
Done: 0,
GeneratedThumbs: 0,
GeneratedPreviews: 0,
Skipped: 0,
StartedAt: now,
FinishedAt: nil,
Error: "",
CurrentFile: "Scanne…", // optional aber sehr hilfreich fürs “Startgefühl”
}
st := assetsTaskState
assetsTaskMu.Unlock()
// 3) SSE push (debounced broadcaster sendet Snapshot)
notifyAssetsChanged()
go runGenerateMissingAssets(ctx)
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" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-BC3HxqFv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-e_Qq8t1c.css">
<script type="module" crossorigin src="/assets/index-Czg-nDKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nA-1muWw.css">
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,15 @@ type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
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
onStopJob: (id: string) => void
blurPreviews?: boolean
@ -289,6 +298,7 @@ function DownloadsCardRow({
nowMs,
blurPreviews,
modelsByKey,
roomStatusByModelKey,
stopRequestedIds,
postworkInfoOf,
markStopRequested,
@ -302,6 +312,15 @@ function DownloadsCardRow({
nowMs: number
blurPreviews?: 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>
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
markStopRequested: (ids: string | string[]) => void
@ -439,8 +458,11 @@ function DownloadsCardRow({
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : ''
const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined
const room = key ? roomStatusByModelKey[key] : undefined
const roomShow = normalizeRoomShow(room?.current_show)
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
@ -481,12 +503,7 @@ function DownloadsCardRow({
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbTick={Number((j as any).previewTick ?? 0)}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full"
/>
@ -503,13 +520,12 @@ function DownloadsCardRow({
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',
'bg-gray-900/5 text-gray-800 dark:bg-white/10 dark:text-gray-200',
isStopping ? 'ring-1 ring-amber-500/30' : 'ring-1 ring-emerald-500/25',
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomBadgeClass(roomShow),
].join(' ')}
title={statusText}
title={roomShow}
>
{statusText}
{roomShow}
</span>
</div>
<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
}
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 => {
if (!Number.isFinite(ms) || ms <= 0) return '—'
const total = Math.floor(ms / 1000)
@ -732,6 +790,7 @@ export default function Downloads({
onToggleWatch,
onAddToDownloads,
modelsByKey = {},
roomStatusByModelKey = {},
blurPreviews
}: Props) {
@ -996,12 +1055,7 @@ export default function Downloads({
<ModelPreview
jobId={j.id}
blur={blurPreviews}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbTick={Number((j as any).previewTick ?? 0)}
thumbsCandidates={jobThumbsWebpCandidates(j)}
className="w-full h-full"
/>
@ -1051,21 +1105,9 @@ export default function Downloads({
const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output)
const rawStatus = String(j.status ?? '').toLowerCase()
// Final "stopped" sauber erkennen (inkl. UI-Stop)
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
const key = modelKeyFromJob(j)
const room = key ? roomStatusByModelKey[key] : undefined
const roomShow = normalizeRoomShow(room?.current_show)
return (
<>
@ -1073,24 +1115,15 @@ export default function Downloads({
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
{ /* Status-Badge */}
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
rawStatus === 'running'
? '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',
roomBadgeClass(roomShow),
].join(' ')}
title={badgeText}
title={roomShow}
>
{badgeText}
{roomShow}
</span>
</div>
<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 key = modelNameFromOutput(j.output || '')
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
const key = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined
const isFav = Boolean(flags?.favorite)
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 list = jobsLive
@ -1450,6 +1483,7 @@ export default function Downloads({
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
@ -1475,6 +1509,7 @@ export default function Downloads({
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
@ -1500,6 +1535,7 @@ export default function Downloads({
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}

View File

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

View File

@ -323,6 +323,7 @@ function chooseSpriteGrid(count: number): [number, number] {
type ChaturbateRoom = {
gender?: string
location?: string
country?: string
current_show?: string
username?: string
room_subject?: string
@ -434,6 +435,9 @@ type StoredModel = {
keep?: boolean
createdAt?: string
updatedAt?: string
cbOnlineJson?: string | null
cbOnlineFetchedAt?: string | null
cbOnlineLastError?: string | null
}
type Props = {
@ -527,7 +531,6 @@ export default function ModelDetails({
//const isDesktop = useMediaQuery('(min-width: 640px)')
const [models, setModels] = React.useState<StoredModel[]>([])
const [, setModelsLoading] = React.useState(false)
const [room, setRoom] = React.useState<ChaturbateRoom | 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 [bioRefreshSeq, setBioRefreshSeq] = React.useState(0)
const [, setBioRefreshSeq] = React.useState(0)
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
@ -559,6 +562,12 @@ export default function ModelDetails({
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) =====
const [durations, setDurations] = React.useState<Record<string, number>>({})
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
@ -613,6 +622,54 @@ export default function ModelDetails({
setStopPending(false)
}, [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 () => {
try {
const r = await fetch('/api/models', { cache: 'no-store' })
@ -647,6 +704,35 @@ export default function ModelDetails({
}
}, [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
function jobFromModelKey(key: string): RecordJob {
// 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(() => {
return Array.isArray(runningJobs) ? runningJobs : running
}, [runningJobs, running])
}, [runningJobs, running])
React.useEffect(() => {
if (!open) return
setDonePage(1)
}, [open, modelKey])
// Models list (local flags + stored tags)
React.useEffect(() => {
if (!open) return
let alive = true
setModelsLoading(true)
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])
void refetchModels()
}, [open, refetchModels])
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
React.useEffect(() => {
@ -857,6 +812,26 @@ export default function ModelDetails({
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [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 runningMatches = React.useMemo(() => {
@ -867,24 +842,12 @@ export default function ModelDetails({
})
}, [runningList, key])
const allTags = React.useMemo(() => {
const a = splitTags(model?.tags)
const b = Array.isArray(room?.tags) ? room!.tags : []
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, room?.tags])
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
const heroImgFull = effectiveRoom?.image_url || heroImg
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
const titleName = room?.display_name || model?.modelKey || key || 'Model'
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 showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
const showPill = showLabel
? showLabel === 'public'
? 'Public'
@ -910,6 +873,18 @@ export default function ModelDetails({
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
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 = ({
icon,
label,
@ -1113,9 +1088,6 @@ export default function ModelDetails({
[setScrubIndexForKey]
)
type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info')
React.useEffect(() => {
if (!open) return
setTab('info')
@ -1139,6 +1111,22 @@ export default function ModelDetails({
mobileCollapsedImageSrc={heroImg || undefined}
mobileCollapsedImageAlt={titleName}
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={
<div className="space-y-3 sm:space-y-4">
@ -1195,7 +1183,7 @@ export default function ModelDetails({
</span>
) : null}
{room?.is_hd ? (
{effectiveRoom?.is_hd ? (
<span
className={pill(
'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>
) : null}
{room?.is_new ? (
{effectiveRoom?.is_new ? (
<span
className={pill(
'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 */}
<div className="absolute bottom-3 left-3 right-3">
<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 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>
@ -1338,11 +1326,11 @@ export default function ModelDetails({
{/* Summary */}
<div className="p-3 sm:p-4">
<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
icon={<SparklesIcon className="size-4" />}
label="Follower"
value={fmtInt(room?.num_followers ?? bioFollowers)}
value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
/>
</div>
@ -1353,7 +1341,7 @@ export default function ModelDetails({
Location
</dt>
<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>
</div>
@ -1363,7 +1351,7 @@ export default function ModelDetails({
Sprache
</dt>
<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>
</div>
@ -1373,7 +1361,7 @@ export default function ModelDetails({
Online
</dt>
<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>
</div>
@ -1383,7 +1371,7 @@ export default function ModelDetails({
Alter
</dt>
<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>
</div>
@ -1399,20 +1387,20 @@ export default function ModelDetails({
</dl>
{/* 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">
Chaturbate-Online ist aktuell deaktiviert.
</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="font-medium">Online-Info: {errorSummary(roomMeta.lastError)}</div>
<div className="font-medium">Online-Info: {errorSummary(effectiveRoomMeta.lastError)}</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(roomMeta.lastError)}
{errorDetails(effectiveRoomMeta.lastError)}
</pre>
</details>
</div>
@ -1457,283 +1445,211 @@ export default function ModelDetails({
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
{/* ===================== */}
<div className="sm:hidden px-2 pb-2 space-y-1.5">
{/* Header Card (dein bisheriger Mobile-Block) */}
<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="flex items-start gap-3">
{/* Avatar */}
<button
type="button"
className="relative shrink-0 overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-white/10"
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
aria-label="Bild vergrößern"
>
{heroImg ? (
{/* HERO Header (mobile) */}
<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">
{/* Hero Background */}
<div className="relative h-40">
{heroImg ? (
<button
type="button"
className="absolute inset-0 block w-full"
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
aria-label="Bild vergrößern"
>
<img
src={heroImg}
src={heroImgFull || heroImg}
alt={titleName}
className={cn('size-10 object-cover', previewBlurCls(blurPreviews))}
className={cn('h-full w-full object-cover', previewBlurCls(blurPreviews))}
/>
) : (
<div className="size-10 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
)}
</button>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
)}
{/* Status dot */}
<span
aria-hidden
className={cn(
'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>
{/* Gradient overlay */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/0"
/>
{/* Name + actions (right) + pills */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
{/* Name */}
<div className="min-w-0">
<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>
{/* Top row: name + action icons */}
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-base font-semibold text-white drop-shadow">
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
</div>
{/* ✅ Buttons rechts neben Name */}
<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 className="truncate text-xs text-white/85 drop-shadow">
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
</div>
</div>
{/* Pills (jetzt unter der Namenszeile) */}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{showPill ? (
<span
className={pill(
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
)}
>
{showPill}
<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/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>
) : null}
</button>
{bioStatus ? (
<span
className={pill(
bioStatus.toLowerCase() === 'online'
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15'
)}
>
{bioStatus}
{/* 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/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>
) : null}
</button>
{room?.is_hd ? (
<span
className={pill(
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20'
)}
>
HD
{/* 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/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>
) : null}
{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}
</button>
</div>
</div>
{/* Row unten: nur Room-Link (optional) */}
{roomUrl ? (
<div className="mt-1.5 flex justify-end">
<a
href={roomUrl}
target="_blank"
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>
{/* Pills bottom-left */}
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
{showPill ? (
<span className={pill('bg-white/15 text-white ring-white/20')}>
{showPill}
</span>
) : null}
</div>
</div>
{/* Quick stats (compact row) */}
<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 className="inline-flex items-center gap-1">
<UsersIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.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(room?.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(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>
{effectivePresenceLabel ? (
<span
className={pill(
(effectivePresenceLabel || '').toLowerCase() === 'online'
? 'bg-emerald-500/25 text-white ring-emerald-200/30'
: 'bg-white/15 text-white ring-white/20'
)}
>
{effectivePresenceLabel}
</span>
) : null}
{/* Meta warnings (mobile) */}
{roomMeta?.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.
{effectiveRoom?.is_hd ? <span className={pill('bg-white/15 text-white ring-white/20')}>HD</span> : null}
{effectiveRoom?.is_new ? <span className={pill('bg-white/15 text-white ring-white/20')}>NEW</span> : null}
</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 ? (
<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>
{/* Room link bottom-right */}
{roomUrl ? (
<div className="absolute right-3 bottom-3">
<a
href={roomUrl}
target="_blank"
rel="noreferrer"
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>
<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>
) : null}
</div>
{/* Quick stats row (unter dem Hero) */}
<div className="px-3 py-2 text-[12px] text-gray-700 dark:text-gray-200">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1">
<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>
) : 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>
{/* Tags (mobile, compact row) */}
@ -1764,15 +1680,15 @@ export default function ModelDetails({
{/* ===================== */}
{/* (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 */}
<div className="min-w-0">
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
{key ? (
<div className="flex flex-wrap gap-x-2 gap-y-1">
{roomMeta?.fetchedAt ? (
{effectiveRoomMeta?.fetchedAt ? (
<span className="text-gray-500 dark:text-gray-400">
Online-Stand: {fmtDateTime(roomMeta.fetchedAt)}
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
</span>
) : null}
{bioMeta?.fetchedAt ? (
@ -1802,21 +1718,6 @@ export default function ModelDetails({
{/* Actions */}
<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 ? (
<a
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>
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
{room?.room_subject ? (
<p className="line-clamp-4 whitespace-pre-wrap break-words">{room.room_subject}</p>
{effectiveRoom?.room_subject ? (
<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>
)}

View File

@ -9,19 +9,9 @@ import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
jobId: string
thumbTick?: number
autoTickMs?: number
blur?: boolean
className?: string
fit?: 'cover' | 'contain'
alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null
alignEveryMs?: number
fastRetryMs?: number
fastRetryMax?: number
fastRetryWindowMs?: number
thumbsWebpUrl?: string | null
thumbsCandidates?: Array<string | null | undefined>
}
@ -29,15 +19,8 @@ type Props = {
export default function ModelPreview({
jobId,
thumbTick,
autoTickMs = 10_000,
blur = false,
className,
alignStartAt,
alignEndAt = null,
alignEveryMs,
fastRetryMs,
fastRetryMax,
fastRetryWindowMs,
thumbsWebpUrl,
thumbsCandidates,
}: Props) {
@ -53,23 +36,10 @@ export default function ModelPreview({
const [inView, setInView] = useState(false)
const inViewRef = useRef(false)
const [localTick, setLocalTick] = useState(0)
const [directImgError, setDirectImgError] = useState(false)
const [apiImgError, setApiImgError] = useState(false)
const retryT = useRef<number | null>(null)
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 [, setPageVisible] = useState(true)
const normalizeUrl = (u?: string | null): string => {
const s = String(u ?? '').trim()
@ -106,12 +76,6 @@ export default function ModelPreview({
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
return () => {
if (retryT.current) window.clearTimeout(retryT.current)
}
}, [])
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
useEffect(() => {
const el = rootRef.current
@ -136,69 +100,8 @@ export default function ModelPreview({
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
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
const rawTick = typeof thumbTick === 'number' ? thumbTick : 0
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
// Wir "freezen" den Tick, solange inView=false oder tab hidden
@ -212,28 +115,15 @@ export default function ModelPreview({
setFrozenTick(rawTick)
}, [rawTick, inView])
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
useEffect(() => {
setDirectImgError(false)
setApiImgError(false)
}, [frozenTick])
// 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])
}, [frozenTick, jobId, thumbsCandidatesKey])
const v = frozenTick || 1
const thumb = useMemo(
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
[jobId, frozenTick]
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${v}`,
[jobId, v]
)
const hq = useMemo(
@ -251,10 +141,10 @@ export default function ModelPreview({
const currentImgSrc = useMemo(() => {
if (useDirectThumb) {
const sep = directThumb.includes('?') ? '&' : '?'
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}`
return `${directThumb}${sep}v=${encodeURIComponent(String(v))}`
}
return thumb
}, [useDirectThumb, directThumb, frozenTick, thumb])
}, [useDirectThumb, directThumb, v, thumb])
return (
<HoverPopover
@ -311,42 +201,18 @@ export default function ModelPreview({
alt=""
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
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)
else setApiImgError(false)
}}
onError={() => {
// 1) Wenn direkte preview.webp fehlschlägt -> auf API-Fallback umschalten
// direct preview.webp kaputt -> auf /api/preview fallback
if (useDirectThumb) {
setDirectImgError(true)
return
}
// 2) API-Fallback fehlschlägt -> bisherige Retry-Logik
// API kaputt -> placeholder
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)
setErr(null)
try {
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' })
setModels(Array.isArray(list) ? list : [])
const res = await fetch('/api/models', { cache: 'no-store' as any })
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()
} catch (e: any) {
setErr(e?.message ?? String(e))

View File

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

View File

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

View File

@ -422,12 +422,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined,
/*
boxShadow:
dx !== 0
? 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(244,63,94,${0.08 + reveal * 0.12})`
: undefined,
*/
borderRadius: dx !== 0 ? '12px' : undefined,
filter:
dx !== 0

View File

@ -186,19 +186,21 @@ export default function Task({
const unsub = subscribeSSE<TaskState>(STREAM_URL, TASK_STATE_EVENT, (st) => {
setState(st)
if (st?.running) {
const isRunning = Boolean(st?.running)
if (isRunning) {
const ac = ensureControllerCreated()
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) {
lastErrorRef.current = errText
onErrorRef.current?.(errText)