updated
This commit is contained in:
parent
81f02c9941
commit
c944483fe6
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
275
backend/main.go
275
backend/main.go
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
261
backend/performance.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -33,6 +33,8 @@ func main() {
|
||||
|
||||
store := registerRoutes(mux, auth)
|
||||
|
||||
initSSE()
|
||||
|
||||
go startChaturbateOnlinePoller(store)
|
||||
go startChaturbateAutoStartWorker(store)
|
||||
go startMyFreeCamsAutoStartWorker(store)
|
||||
|
||||
287
backend/sse.go
287
backend/sse.go
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
449
backend/web/dist/assets/index-BC3HxqFv.js
vendored
449
backend/web/dist/assets/index-BC3HxqFv.js
vendored
File diff suppressed because one or more lines are too long
449
backend/web/dist/assets/index-Czg-nDKc.js
vendored
Normal file
449
backend/web/dist/assets/index-Czg-nDKc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-e_Qq8t1c.css
vendored
1
backend/web/dist/assets/index-e_Qq8t1c.css
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-nA-1muWw.css
vendored
Normal file
1
backend/web/dist/assets/index-nA-1muWw.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -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
@ -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}
|
||||
|
||||
@ -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) */}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -102,7 +102,7 @@ function useFps(sampleMs = 1000) {
|
||||
export default function PerformanceMonitor({
|
||||
mode = 'inline',
|
||||
className,
|
||||
pollMs = 3000,
|
||||
pollMs = 1000,
|
||||
}: Props) {
|
||||
const fps = useFps(1000)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user