bug fixes

This commit is contained in:
Linrador 2026-04-11 11:47:54 +02:00
parent e0b4cd589d
commit 3a6e1d0f48
35 changed files with 2160 additions and 1189 deletions

View File

@ -833,88 +833,80 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil return nil
} }
func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool { func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string) bool {
videoPath = strings.TrimSpace(videoPath) outPath = strings.TrimSpace(outPath)
if videoPath == "" { if outPath == "" {
return false return false
} }
id := assetIDFromVideoPath(videoPath) fileName := filepath.Base(outPath)
if id == "" { assetID := assetIDFromVideoPath(outPath)
if assetID == "" {
return false return false
} }
key := "enrich:" + id truth := finishedPhaseTruthForID(assetID)
needMeta := !truth.MetaReady
needThumb := !truth.ThumbReady
needTeaser := !truth.TeaserReady
needSprites := !truth.SpritesReady
needAnalyze := !truth.AnalyzeReady
// Nichts zu tun -> erfolgreich
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
return true
}
// Nur fehlende Phasen als queued publishen
if needMeta {
publishDeferredPhase(fileName, assetID, "postwork", "meta", "queued", "Meta wartet", nil)
}
if needThumb {
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "queued", "Vorschaubild wartet", nil)
}
if needTeaser {
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "queued", "Teaser wartet", nil)
}
if needSprites {
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
}
if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil)
}
ok := enrichQ.Enqueue(PostWorkTask{ ok := enrichQ.Enqueue(PostWorkTask{
Key: key, Key: "enrich:" + assetID,
Added: time.Now(), Added: time.Now(),
Run: func(ctx context.Context) error { Run: func(ctx context.Context) error {
if job != nil { return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
st := enrichQ.StatusForKey(key)
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
"Wird verarbeitet…",
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
}
err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL)
if job != nil {
if err != nil {
publishFinishedPostworkStateForJob(
job,
"enrich",
"error",
"assets",
"Ergänzung fehlgeschlagen",
0,
0,
0,
0,
)
} else {
publishFinishedPostworkStateForJob(
job,
"enrich",
"done",
"",
"Fertig",
0,
0,
0,
0,
)
}
}
return err
}, },
}) })
if ok && job != nil { if ok {
st := enrichQ.StatusForKey(key) return true
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
"Wartet",
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
} }
return ok enqueueErr := fmt.Errorf("enqueue failed")
// queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen
if needMeta {
publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta konnte nicht eingeplant werden", enqueueErr)
}
if needThumb {
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild konnte nicht eingeplant werden", enqueueErr)
}
if needTeaser {
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser konnte nicht eingeplant werden", enqueueErr)
}
if needSprites {
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
}
if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr)
}
return false
} }
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist. // Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.

View File

@ -2,66 +2,74 @@
"items": [ "items": [
{ {
"modelKey": "jam_cream", "modelKey": "jam_cream",
"url": "https://www.myfreecams.com/#jam_cream", "url": "https://www.myfreecams.com/#Jam_cream",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1775738855792, "nextProbeAtMs": 1775899166952,
"currentShow": "offline", "currentShow": "offline",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "tashinadoncaster", "modelKey": "milk_bunny_",
"url": "https://chaturbate.com/tashinadoncaster/", "url": "https://chaturbate.com/milk_bunny_/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tashinadoncaster.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "_jesslei_", "modelKey": "kali_hiill",
"url": "https://chaturbate.com/_jesslei_/", "url": "https://chaturbate.com/kali_hiill/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/_jesslei_.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "mary_meys", "modelKey": "passiondesire_",
"url": "https://chaturbate.com/mary_meys/", "url": "https://chaturbate.com/passiondesire_/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/mary_meys.jpg",
"source": "watched"
},
{
"modelKey": "jodeesolarz",
"url": "https://chaturbate.com/jodeesolarz/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "away", "currentShow": "away",
"imageUrl": "https://thumb.live.mmcdn.com/ri/jodeesolarz.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "di_n_alex", "modelKey": "tsukuyami_mi",
"url": "https://chaturbate.com/di_n_alex/", "url": "https://chaturbate.com/tsukuyami_mi/",
"mode": "wait_public",
"currentShow": "hidden",
"imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg",
"source": "watched"
},
{
"modelKey": "swtty_meivi",
"url": "https://chaturbate.com/swtty_meivi/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/di_n_alex.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "lora_lifelover", "modelKey": "amandatalk",
"url": "https://chaturbate.com/lora_lifelover/", "url": "https://chaturbate.com/amandatalk/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "chaesoffel", "modelKey": "oliver_and_megan",
"url": "https://chaturbate.com/chaesoffel/", "url": "https://chaturbate.com/oliver_and_megan/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "hidden", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/chaesoffel.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg",
"source": "watched"
},
{
"modelKey": "aurora_asti_",
"url": "https://chaturbate.com/aurora_asti_/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg",
"source": "watched" "source": "watched"
}, },
{ {
@ -73,26 +81,34 @@
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "shirleendurrah", "modelKey": "gimbobar",
"url": "https://chaturbate.com/shirleendurrah/", "url": "https://chaturbate.com/gimbobar/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "private", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/shirleendurrah.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "palmiratownzen", "modelKey": "lizbethbiers",
"url": "https://chaturbate.com/palmiratownzen/", "url": "https://chaturbate.com/lizbethbiers/",
"mode": "wait_public", "mode": "wait_public",
"currentShow": "away", "currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg", "imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg",
"source": "watched" "source": "watched"
}, },
{ {
"modelKey": "rafaelaloghry", "modelKey": "bonnettbonett",
"url": "https://chaturbate.com/rafaelaloghry/", "url": "https://chaturbate.com/bonnettbonett/",
"mode": "wait_public",
"currentShow": "private",
"imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg",
"source": "watched"
},
{
"modelKey": "pazrapacki",
"url": "https://chaturbate.com/pazrapacki/",
"mode": "probe_retry", "mode": "probe_retry",
"nextProbeAtMs": 1775738868799, "nextProbeAtMs": 1775899181131,
"currentShow": "unknown", "currentShow": "unknown",
"source": "watched" "source": "watched"
} }

View File

@ -141,10 +141,15 @@ type modelsOverviewRequest struct {
} }
type modelsOverviewModel struct { type modelsOverviewModel struct {
ID string `json:"id"` ID string `json:"id"`
Input string `json:"input"` Input string `json:"input"`
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"` ModelKey string `json:"modelKey"`
Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"`
Subgender string `json:"subgender,omitempty"`
Watching bool `json:"watching"` Watching bool `json:"watching"`
Favorite bool `json:"favorite,omitempty"` Favorite bool `json:"favorite,omitempty"`
Liked *bool `json:"liked,omitempty"` Liked *bool `json:"liked,omitempty"`

View File

@ -17,12 +17,17 @@ import (
) )
type StoredModel struct { type StoredModel struct {
ID string `json:"id"` // unique (wir verwenden host:modelKey) ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key ModelKey string `json:"modelKey"` // Display/Key
Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"`
Subgender string `json:"subgender,omitempty"`
Tags string `json:"tags,omitempty"` Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
@ -1048,13 +1053,27 @@ ORDER BY updated_at DESC;
continue continue
} }
var gender, sex, subgender string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
}
}
m := StoredModel{ m := StoredModel{
ID: id, ID: id,
Input: input, Input: input,
IsURL: isURL, IsURL: isURL,
Host: host, Host: host,
Path: path, Path: path,
ModelKey: modelKey, ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
@ -1870,13 +1889,26 @@ WHERE id=$1;
} }
} }
var gender, sex, subgender string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
}
}
m := StoredModel{ m := StoredModel{
ID: id, ID: id,
Input: input, Input: input,
IsURL: isURL, IsURL: isURL,
Host: host, Host: host,
Path: path, Path: path,
ModelKey: modelKey, ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
Tags: tags, Tags: tags,
LastStream: fmtNullTime(lastStream), LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),

View File

@ -10,6 +10,10 @@ import (
"time" "time"
) )
func normalizeMFCUserKey(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func mfcUserFromURL(raw string) string { func mfcUserFromURL(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@ -42,7 +46,7 @@ func mfcUserFromURL(raw string) string {
for i := len(parts) - 1; i >= 0; i-- { for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i]) p := strings.TrimSpace(parts[i])
if p != "" { if p != "" {
return strings.ToLower(p) return p
} }
} }
} }
@ -51,42 +55,40 @@ func mfcUserFromURL(raw string) string {
for i := len(parts) - 1; i >= 0; i-- { for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i]) p := strings.TrimSpace(parts[i])
if p != "" { if p != "" {
return strings.ToLower(p) return p
} }
} }
return "" return ""
} }
func watchedMyFreeCamsUserKey(m WatchedModelLite) string { func mfcUserKeyFromURL(raw string) string {
key := strings.ToLower(strings.TrimSpace(m.ModelKey)) return normalizeMFCUserKey(mfcUserFromURL(raw))
if key != "" { }
return key
}
in := strings.TrimSpace(m.Input) func watchedMyFreeCamsUsername(m WatchedModelLite) string {
if in == "" { if u := mfcUserFromURL(m.Input); u != "" {
return ""
}
if u := mfcUserFromURL(in); u != "" {
return u return u
} }
return strings.ToLower(strings.Trim(in, "/")) if key := strings.TrimSpace(m.ModelKey); key != "" {
return key
}
return strings.TrimSpace(strings.Trim(m.Input, "/"))
}
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
return normalizeMFCUserKey(watchedMyFreeCamsUsername(m))
} }
func resolveMyFreeCamsURL(m WatchedModelLite) string { func resolveMyFreeCamsURL(m WatchedModelLite) string {
if u := mfcUserFromURL(m.Input); u != "" { user := watchedMyFreeCamsUsername(m)
return fmt.Sprintf("https://www.myfreecams.com/#%s", u) if user == "" {
}
key := watchedMyFreeCamsUserKey(m)
if key == "" {
return "" return ""
} }
return fmt.Sprintf("https://www.myfreecams.com/#%s", key) return fmt.Sprintf("https://www.myfreecams.com/#%s", user)
} }
// Startet watched MyFreeCams Models (ohne API) "best-effort". // Startet watched MyFreeCams Models (ohne API) "best-effort".
@ -156,9 +158,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
manualOrder := make([]string, 0, len(manualItems)) manualOrder := make([]string, 0, len(manualItems))
for _, it := range manualItems { for _, it := range manualItems {
key := strings.ToLower(strings.TrimSpace(it.ModelKey)) key := normalizeMFCUserKey(strings.TrimSpace(it.ModelKey))
if key == "" { if key == "" {
key = mfcUserFromURL(it.URL) key = mfcUserKeyFromURL(it.URL)
} }
if key == "" { if key == "" {
continue continue
@ -211,7 +213,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
continue continue
} }
u := mfcUserFromURL(j.SourceURL) u := mfcUserKeyFromURL(j.SourceURL)
if u == "" { if u == "" {
continue continue
} }

View File

@ -354,7 +354,6 @@ func startEnrichStatusRefresher() {
key := "enrich:" + id key := "enrich:" + id
st := enrichQ.StatusForKey(key) st := enrichQ.StatusForKey(key)
// Nur senden, wenn der Job tatsächlich gerade queued/running ist
if st.State != "queued" && st.State != "running" { if st.State != "queued" && st.State != "running" {
clearEnrichStatusSnapshot(key) clearEnrichStatusSnapshot(key)
continue continue
@ -364,22 +363,10 @@ func startEnrichStatusRefresher() {
continue continue
} }
label := "Wartet" // Wichtig:
if st.State == "running" { // Hier NICHT mehr pauschal "analyze" publishen.
label = "Wird verarbeitet…" // Die echten Phasen kommen direkt aus dem Enrich-Worker selbst:
} // meta/thumb/teaser (nur falls fehlend), danach sprites, danach analyze.
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
label,
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
} }
} }
}() }()

View File

@ -26,6 +26,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"reflect"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@ -1532,36 +1533,117 @@ func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *
servePreviewStatusSVG(w, label, status) servePreviewStatusSVG(w, label, status)
} }
func serveRunningJobPreview(w http.ResponseWriter, r *http.Request, job *RecordJob) bool {
if job == nil {
return false
}
provider := detectPreviewProvider(job, job.Output)
previewState := previewStateForJob(job)
imageURL := strings.TrimSpace(previewImageURLForJob(job))
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
previewCookie := strings.TrimSpace(job.PreviewCookie)
previewUA := strings.TrimSpace(job.PreviewUA)
outPath := strings.TrimSpace(job.Output)
// Chaturbate: immer CDN bevorzugen, nichts auf Platte schreiben
if provider == "chaturbate" && imageURL != "" {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if img, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(img) > 0 {
job.previewMu.Lock()
job.previewJPG = append([]byte(nil), img...)
job.previewJPGAt = time.Now()
job.previewMu.Unlock()
serveLivePreviewJPGBytes(w, img)
return true
}
}
job.previewMu.Lock()
cached := append([]byte(nil), job.previewJPG...)
cachedAt := job.previewJPGAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 18*time.Second
canSpawn := !fresh && !job.previewGen && provider != "chaturbate"
if canSpawn {
job.previewGen = true
}
job.previewMu.Unlock()
if fresh || (provider == "chaturbate" && len(cached) > 0) {
serveLivePreviewJPGBytes(w, cached)
return true
}
// Für Nicht-Chaturbate darf weiterhin in-memory erzeugt werden, aber nicht als preview.jpg speichern
if canSpawn {
go func(j *RecordJob, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
if previewState == "public" && imageURL != "" {
if b, err := downloadPreviewImageJPG(context.Background(), imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && previewM3U8 != "" {
if b, err := extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && outPath != "" {
if b, err := extractLastFrameJPGScaled(outPath, 720, 75); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 {
return
}
j.previewMu.Lock()
j.previewJPG = append([]byte(nil), img...)
j.previewJPGAt = time.Now()
j.previewMu.Unlock()
}(job, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath)
}
return false
}
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) { func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
jobsMu.RLock() jobsMu.RLock()
job := jobs[id] job := jobs[id]
jobsMu.RUnlock() jobsMu.RUnlock()
if job != nil { if job != nil {
if job.Status == JobRunning {
if serveRunningJobPreview(w, r, job) {
return
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
assetID := assetIDForJob(job) assetID := assetIDForJob(job)
if assetID != "" { if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil { if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 { if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
if job.Status == JobRunning { servePreviewJPGFile(w, r, jpgPath)
serveLivePreviewJPGFile(w, r, jpgPath)
} else {
servePreviewJPGFile(w, r, jpgPath)
}
return return
} }
} }
} }
if job.Status == JobRunning {
job.previewMu.Lock()
cached := job.previewJPG
job.previewMu.Unlock()
if len(cached) > 0 {
serveLivePreviewJPGBytes(w, cached)
return
}
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return return
} }
@ -1634,6 +1716,14 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
low := strings.ToLower(strings.TrimSpace(file)) low := strings.ToLower(strings.TrimSpace(file))
if low == "preview.jpg" { if low == "preview.jpg" {
jobsMu.RLock()
job := jobs[id]
jobsMu.RUnlock()
if job != nil && job.Status == JobRunning {
startLiveThumbJPGLoop(context.Background(), job)
}
servePreviewJPGAlias(w, r, id) servePreviewJPGAlias(w, r, id)
return return
} }
@ -1649,82 +1739,34 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if ok { if ok {
if job.Status == JobRunning { if job.Status == JobRunning {
assetID := assetIDForJob(job) if serveRunningJobPreview(w, r, job) {
if assetID != "" { return
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil { }
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
serveLivePreviewJPGFile(w, r, jpgPath) state := strings.TrimSpace(previewStateForJob(job))
return if state == "private" {
} serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return
}
if state == "offline" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
return
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
assetID := assetIDForJob(job)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewJPGFile(w, r, jpgPath)
return
} }
} }
} }
job.previewMu.Lock()
cached := job.previewJPG
cachedAt := job.previewJPGAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
previewM3U8 := strings.TrimSpace(j.PreviewM3U8)
previewCookie := strings.TrimSpace(j.PreviewCookie)
previewUA := strings.TrimSpace(j.PreviewUA)
if previewM3U8 != "" {
img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA)
if genErr != nil {
// optional loggen
}
}
if len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
img, _ = extractLastFrameJPGScaled(outPath, 720, 75)
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewJPG = img
j.previewJPGAt = time.Now()
j.previewMu.Unlock()
}
}(job)
}
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
serveLivePreviewJPGBytes(w, out)
return
}
jobsMu.RLock()
state := strings.TrimSpace(job.PreviewState)
jobsMu.RUnlock()
if state == "private" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return
}
if state == "offline" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
return
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK) serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return return
} }
@ -1733,6 +1775,210 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
servePreviewForFinishedFile(w, r, id) servePreviewForFinishedFile(w, r, id)
} }
func reflectStringField(v any, names ...string) string {
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return ""
}
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
rv = rv.Elem()
}
if !rv.IsValid() || rv.Kind() != reflect.Struct {
return ""
}
for _, name := range names {
f := rv.FieldByName(name)
if !f.IsValid() || f.Kind() != reflect.String {
continue
}
s := strings.TrimSpace(f.String())
if s != "" {
return s
}
}
return ""
}
func inferModelFromSourceURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u == nil {
return ""
}
host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www."))
switch {
case strings.Contains(host, "chaturbate.com"):
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) > 0 {
return strings.ToLower(strings.TrimSpace(parts[0]))
}
case strings.Contains(host, "myfreecams.com"):
hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/")
if hash != "" {
parts := strings.Split(hash, "/")
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return strings.ToLower(last)
}
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) > 0 {
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return strings.ToLower(last)
}
}
}
return ""
}
func previewModelKeyForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job, "Model", "ModelKey"); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
if s := reflectStringField(job, "SourceURL", "SourceUrl"); s != "" {
if mk := inferModelFromSourceURL(s); mk != "" {
return mk
}
}
if s := reflectStringField(job, "Output"); s != "" {
if mk := inferModelFromThumbLike(s); mk != "" {
return strings.ToLower(strings.TrimSpace(mk))
}
}
return ""
}
func previewStateForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job,
"PreviewState",
"RoomStatus",
"ModelStatus",
"CurrentShow",
"Show",
"StatusText",
); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
modelKey := previewModelKeyForJob(job)
if modelKey == "" || coverModelStore == nil {
return ""
}
models := coverModelStore.List()
for _, m := range models {
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
if mk == "" || mk != modelKey {
continue
}
if s := reflectStringField(m,
"RoomStatus",
"CurrentShow",
"Status",
"ModelStatus",
); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
}
return ""
}
func previewImageURLForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job,
"ModelImageURL",
"ModelImageUrl",
"ImageURL",
"ImageUrl",
"PreviewImageURL",
"PreviewImageUrl",
); s != "" {
return s
}
modelKey := previewModelKeyForJob(job)
if modelKey == "" || coverModelStore == nil {
return ""
}
models := coverModelStore.List()
for _, m := range models {
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
if mk == "" || mk != modelKey {
continue
}
if s := reflectStringField(m,
"ImageURL",
"ImageUrl",
"ModelImageURL",
"ModelImageUrl",
); s != "" {
return s
}
}
return ""
}
func imageBytesToJPG(raw []byte, quality int) ([]byte, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("empty image")
}
if quality <= 0 || quality > 100 {
quality = 80
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, err
}
if buf.Len() == 0 {
return nil, fmt.Errorf("jpeg encode returned empty data")
}
return buf.Bytes(), nil
}
func downloadPreviewImageJPG(ctx context.Context, rawURL, ua string) ([]byte, error) {
raw, _, err := downloadBytes(ctx, rawURL, ua)
if err != nil {
return nil, err
}
return imageBytesToJPG(raw, 82)
}
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.RLock() jobsMu.RLock()
status := job.Status status := job.Status
@ -1746,21 +1992,16 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
return return
} }
assetID := assetIDForJob(job) job.previewMu.Lock()
thumbPath, err := generatedThumbJPGFile(assetID) lastAt := job.previewJPGAt
if err != nil { job.previewMu.Unlock()
if !lastAt.IsZero() && time.Since(lastAt) < 18*time.Second {
return return
} }
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
// etwas Puffer, damit die 10s-Loop nicht an exakten Grenzwerten hängen bleibt
if time.Since(st.ModTime()) < 9*time.Second {
return
}
}
if thumbSem != nil { if thumbSem != nil {
thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second) thumbCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel() defer cancel()
if err := thumbSem.Acquire(thumbCtx); err != nil { if err := thumbSem.Acquire(thumbCtx); err != nil {
return return
@ -1770,15 +2011,33 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
var img []byte var img []byte
if previewM3U8 != "" { provider := detectPreviewProvider(job, out)
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 { previewState := previewStateForJob(job)
img = b imageURL := strings.TrimSpace(previewImageURLForJob(job))
}
}
if len(img) == 0 && out != "" { if provider == "chaturbate" {
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 { if imageURL != "" {
img = b if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
} else {
if previewState == "public" && imageURL != "" {
if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && previewM3U8 != "" {
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && out != "" {
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b
}
} }
} }
@ -1786,7 +2045,10 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
return return
} }
_ = atomicWriteFile(thumbPath, img) job.previewMu.Lock()
job.previewJPG = append([]byte(nil), img...)
job.previewJPGAt = time.Now()
job.previewMu.Unlock()
} }
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) { func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
@ -1804,7 +2066,7 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-time.After(10 * time.Second): case <-time.After(20 * time.Second):
jobsMu.RLock() jobsMu.RLock()
st := job.Status st := job.Status
jobsMu.RUnlock() jobsMu.RUnlock()

View File

@ -91,11 +91,6 @@ func RecordStream(
job.PreviewCookie = httpCookie job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
// Live-Thumbs sind teuer: bei vielen parallelen Downloads nicht für alle starten.
if assetID != "" && activeRecordingJobsCount() <= 2 {
startLiveThumbJPGLoop(ctx, job)
}
} }
const maxPlaylistRefreshes = 12 const maxPlaylistRefreshes = 12

View File

@ -1159,6 +1159,167 @@ func buildPostworkPhaseUpdater(job *RecordJob, postKey string, out *string) func
} }
} }
func publishDeferredPhase(fileName, assetID, queue, phase, state, label string, err error) {
errText := ""
if err != nil {
errText = strings.TrimSpace(err.Error())
}
publishFinishedPostworkPhase(
fileName,
assetID,
queue,
phase,
state,
label,
errText,
)
}
func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return fmt.Errorf("output path leer")
}
fileName := filepath.Base(outPath)
assetID := assetIDFromVideoPath(outPath)
if assetID == "" {
return fmt.Errorf("asset id fehlt")
}
// best effort sourceURL aus meta.json nachziehen, falls leer
if strings.TrimSpace(sourceURL) == "" {
if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil {
if fi, serr := os.Stat(outPath); serr == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
if u, ok := readVideoMetaSourceURL(metaPath, fi); ok {
sourceURL = u
}
}
}
}
abortIfCanceled := func(stepErr error, queue string, phase string) error {
if errors.Is(stepErr, context.Canceled) ||
errors.Is(stepErr, context.DeadlineExceeded) ||
ctx.Err() != nil {
publishDeferredPhase(fileName, assetID, queue, phase, "missing", "", nil)
return context.Canceled
}
return nil
}
truth := finishedPhaseTruthForID(assetID)
// -------------------------------------------------
// Fehlende Vorstufen nur nachziehen, NICHT alles neu
// -------------------------------------------------
if !truth.MetaReady {
publishDeferredPhase(fileName, assetID, "postwork", "meta", "running", "Meta", nil)
okMeta, err := ensureMetaForVideoCtx(ctx, outPath, sourceURL)
if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil {
return cerr
}
if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady {
if err == nil {
err = fmt.Errorf("meta konnte nicht erzeugt werden")
}
publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err)
return err
}
publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil)
truth = finishedPhaseTruthForID(assetID)
}
if !truth.ThumbReady {
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "running", "Vorschaubild", nil)
_, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, outPath, sourceURL, nil)
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady {
if err == nil {
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden")
}
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err)
return err
}
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil)
truth = finishedPhaseTruthForID(assetID)
}
if !truth.TeaserReady {
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "running", "Teaser", nil)
_, err := ensureTeaserForVideoCtx(ctx, outPath, sourceURL)
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady {
if err == nil {
err = fmt.Errorf("teaser konnte nicht erzeugt werden")
}
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err)
return err
}
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil)
truth = finishedPhaseTruthForID(assetID)
}
// Ab hier erst die langsamen Hintergrundphasen
var firstErr error
if !truth.SpritesReady {
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil)
_, err := ensureSpritesForVideoCtx(ctx, outPath, sourceURL)
if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady {
if err == nil {
err = fmt.Errorf("sprites konnten nicht erzeugt werden")
}
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err)
if firstErr == nil {
firstErr = err
}
} else {
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
}
}
truth = finishedPhaseTruthForID(assetID)
if !truth.AnalyzeReady {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil)
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw")
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
return cerr
}
if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady {
if err == nil {
err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden")
}
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err)
if firstErr == nil {
firstErr = err
}
} else {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil)
}
}
return firstErr
}
func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) { func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) {
finalStatus := JobFailed finalStatus := JobFailed
if postTarget == JobStopped { if postTarget == JobStopped {
@ -1223,15 +1384,6 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok { if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
fmt.Println("⚠️ deferred enrichment enqueue failed:", out) fmt.Println("⚠️ deferred enrichment enqueue failed:", out)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile,
AssetID: finalAssetID,
Queue: "enrich",
State: "error",
Phase: "assets",
Label: "Späte Nachbearbeitung konnte nicht gestartet werden",
})
} }
if postTarget == JobStopped { if postTarget == JobStopped {

View File

@ -9,8 +9,8 @@
"maxConcurrentDownloads": 80, "maxConcurrentDownloads": 80,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": false, "autoDeleteSmallDownloads": true,
"autoDeleteSmallDownloadsBelowMB": 100, "autoDeleteSmallDownloadsBelowMB": 300,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,
"teaserPlayback": "all", "teaserPlayback": "all",

View File

@ -18,6 +18,7 @@ import (
// --------------------------- // ---------------------------
type AssetsTaskState struct { type AssetsTaskState struct {
Queued bool `json:"queued"`
Running bool `json:"running"` Running bool `json:"running"`
Total int `json:"total"` Total int `json:"total"`
Done int `json:"done"` Done int `json:"done"`
@ -31,6 +32,7 @@ type AssetsTaskState struct {
CurrentQueue string `json:"currentQueue,omitempty"` CurrentQueue string `json:"currentQueue,omitempty"`
CurrentPhase string `json:"currentPhase,omitempty"` CurrentPhase string `json:"currentPhase,omitempty"`
CurrentLabel string `json:"currentLabel,omitempty"` CurrentLabel string `json:"currentLabel,omitempty"`
Text string `json:"text,omitempty"`
} }
var assetsTaskMu sync.Mutex var assetsTaskMu sync.Mutex
@ -71,32 +73,31 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
case http.MethodPost: case http.MethodPost:
assetsTaskMu.Lock() assetsTaskMu.Lock()
if assetsTaskState.Running { if assetsTaskState.Running || assetsTaskState.Queued {
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
} }
// cancelbarer Context (pro Run)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
assetsTaskCancel = cancel assetsTaskCancel = cancel
now := time.Now()
assetsTaskState = AssetsTaskState{ assetsTaskState = AssetsTaskState{
Running: true, Queued: true,
Running: false,
Total: 0, Total: 0,
Done: 0, Done: 0,
GeneratedThumbs: 0, GeneratedThumbs: 0,
GeneratedPreviews: 0, GeneratedPreviews: 0,
Skipped: 0, Skipped: 0,
StartedAt: now, StartedAt: time.Time{},
FinishedAt: nil, FinishedAt: nil,
Error: "", Error: "",
CurrentFile: "", CurrentFile: "",
CurrentQueue: "", CurrentQueue: "",
CurrentPhase: "", CurrentPhase: "",
CurrentLabel: "", CurrentLabel: "",
Text: "Wartet…",
} }
st := assetsTaskState st := assetsTaskState
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
@ -104,7 +105,32 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
notifyAssetsChanged() notifyAssetsChanged()
publishTaskState() publishTaskState()
go runGenerateMissingAssets(ctx) go func() {
if err := acquireExclusiveTask(ctx); err != nil {
now := time.Now()
updateAssetsState(func(st *AssetsTaskState) {
st.Queued = false
st.Running = false
st.FinishedAt = &now
if errors.Is(err, context.Canceled) {
st.Error = "Abgebrochen"
} else {
st.Error = err.Error()
}
})
return
}
defer releaseExclusiveTask()
updateAssetsState(func(st *AssetsTaskState) {
st.Queued = false
st.Running = true
st.StartedAt = time.Now()
st.Text = ""
})
runGenerateMissingAssets(ctx)
}()
writeJSON(w, http.StatusOK, st) writeJSON(w, http.StatusOK, st)
return return
@ -113,9 +139,10 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
assetsTaskMu.Lock() assetsTaskMu.Lock()
cancel := assetsTaskCancel cancel := assetsTaskCancel
running := assetsTaskState.Running running := assetsTaskState.Running
queued := assetsTaskState.Queued
assetsTaskMu.Unlock() assetsTaskMu.Unlock()
if !running || cancel == nil { if (!running && !queued) || cancel == nil {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }
@ -126,7 +153,9 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
// UI sofort informieren (ohne Running künstlich auf false zu setzen — // UI sofort informieren (ohne Running künstlich auf false zu setzen —
// das macht der Worker zuverlässig im finishWithErr(context.Canceled)) // das macht der Worker zuverlässig im finishWithErr(context.Canceled))
st := updateAssetsState(func(st *AssetsTaskState) { st := updateAssetsState(func(st *AssetsTaskState) {
if st.Running { if st.Running || st.Queued {
st.Queued = false
st.Running = false
st.Error = "Abgebrochen" st.Error = "Abgebrochen"
} }
}) })
@ -189,6 +218,7 @@ func runGenerateMissingAssets(ctx context.Context) {
now := time.Now() now := time.Now()
updateAssetsState(func(st *AssetsTaskState) { updateAssetsState(func(st *AssetsTaskState) {
st.Queued = false
st.Running = false st.Running = false
st.FinishedAt = &now st.FinishedAt = &now
st.CurrentFile = "" st.CurrentFile = ""

View File

@ -90,6 +90,13 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
mb = 0 mb = 0
} }
// ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind
if err := acquireExclusiveTask(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
defer releaseExclusiveTask()
setCleanupTaskRunning("Räume auf…") setCleanupTaskRunning("Räume auf…")
resp := cleanupResp{} resp := cleanupResp{}

View File

@ -0,0 +1,33 @@
// backend\tasks_exclusive.go
package main
import (
"context"
)
var exclusiveTaskSem = make(chan struct{}, 1)
func init() {
exclusiveTaskSem <- struct{}{}
}
func acquireExclusiveTask(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
return ctx.Err()
case <-exclusiveTaskSem:
return nil
}
}
func releaseExclusiveTask() {
select {
case exclusiveTaskSem <- struct{}{}:
default:
}
}

View File

@ -196,14 +196,29 @@ func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob {
continue continue
} }
job.State = "running"
job.StartedAt = time.Now()
return job return job
} }
return nil return nil
} }
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
job := regenerateAssetsJobs[file]
if job == nil {
return nil
}
if job.State != "queued" {
return nil
}
job.State = "running"
job.StartedAt = time.Now()
return job
}
func finishRegenerateAssetsJob(file string, state string, errMsg string) { func finishRegenerateAssetsJob(file string, state string, errMsg string) {
regenerateAssetsMu.Lock() regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock() defer regenerateAssetsMu.Unlock()
@ -305,7 +320,24 @@ func regenerateAssetsWorker() {
continue continue
} }
runRegenerateAssetsJob(job) // ✅ wartet global, bis kein anderer exklusiver Task läuft
if err := acquireExclusiveTask(job.Ctx); err != nil {
finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(job.File, "Abgebrochen")
removeRegenerateAssetsJobLater(job.File, 2*time.Second)
continue
}
job = markRegenerateAssetsJobRunning(job.File)
if job == nil {
releaseExclusiveTask()
continue
}
func() {
defer releaseExclusiveTask()
runRegenerateAssetsJob(job)
}()
} }
} }

View File

@ -206,7 +206,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
RegenerateAssets: regenerate, RegenerateAssets: regenerate,
RegenerateAssetsJobs: regenerateJobs, RegenerateAssetsJobs: regenerateJobs,
Cleanup: cleanup, Cleanup: cleanup,
AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running, AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View File

@ -16,7 +16,7 @@ import ModelDetails from './components/ui/ModelDetails'
import { import {
SignalIcon, SignalIcon,
HeartIcon, HeartIcon,
HandThumbUpIcon, StarIcon,
EyeIcon, EyeIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
ArchiveBoxIcon, ArchiveBoxIcon,
@ -149,6 +149,7 @@ type StoredModel = {
input: string input: string
host?: string host?: string
modelKey: string modelKey: string
watching: boolean watching: boolean
favorite?: boolean favorite?: boolean
liked?: boolean | null liked?: boolean | null
@ -159,6 +160,11 @@ type StoredModel = {
isOnline?: boolean isOnline?: boolean
chatRoomUrl?: string chatRoomUrl?: string
imageUrl?: string imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
lastOnlineAt?: string lastOnlineAt?: string
lastOfflineAt?: string lastOfflineAt?: string
lastRoomSyncAt?: string lastRoomSyncAt?: string
@ -2318,12 +2324,16 @@ export default function App() {
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot]) }, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot])
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => { const startUrl = useCallback(async (
rawUrl: string,
opts?: { silent?: boolean; immediate?: boolean }
): Promise<boolean> => {
const norm0 = normalizeHttpUrl(rawUrl) const norm0 = normalizeHttpUrl(rawUrl)
if (!norm0) return false if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0) const norm = canonicalizeProviderUrl(norm0)
const silent = Boolean(opts?.silent) const silent = Boolean(opts?.silent)
const immediate = Boolean(opts?.immediate)
if (!silent) setError(null) if (!silent) setError(null)
@ -2360,20 +2370,24 @@ export default function App() {
const keyLower = providerKeyLowerFromUrl(norm) const keyLower = providerKeyLowerFromUrl(norm)
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { // Nur automatische Starts in pending-autostart schieben.
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { // Manuelle UI-Starts müssen sofort direkt starten.
mode: 'probe_retry', if (!immediate) {
source: 'manual', if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
}) queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
return true mode: 'probe_retry',
} source: 'manual',
})
return true
}
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
queuePendingAutoStart(keyLower, norm, 'offline', undefined, { queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
mode: 'probe_retry', mode: 'probe_retry',
source: 'manual', source: 'manual',
}) })
return true return true
}
} }
try { try {
@ -2926,7 +2940,7 @@ export default function App() {
}, [playerJob, modelsByKey]) }, [playerJob, modelsByKey])
async function onStart() { async function onStart() {
const ok = await startUrl(sourceUrl, { silent: false }) const ok = await startUrl(sourceUrl, { silent: false, immediate: true })
if (ok) { if (ok) {
setSourceUrl('') setSourceUrl('')
@ -2958,7 +2972,7 @@ export default function App() {
} }
const url = canonicalizeProviderUrl(norm0) const url = canonicalizeProviderUrl(norm0)
const ok = await startUrl(url, { silent: true }) const ok = await startUrl(url, { silent: true, immediate: true })
if (!ok) { if (!ok) {
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.') notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
@ -3618,12 +3632,12 @@ export default function App() {
</span> </span>
<span className={headerStatBadgeClass} title="Fav online"> <span className={headerStatBadgeClass} title="Fav online">
<HeartIcon className="size-4 opacity-80" /> <StarIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineFavCount}</span> <span className="tabular-nums">{onlineFavCount}</span>
</span> </span>
<span className={headerStatBadgeClass} title="Like online"> <span className={headerStatBadgeClass} title="Like online">
<HandThumbUpIcon className="size-4 opacity-80" /> <HeartIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineLikedCount}</span> <span className="tabular-nums">{onlineLikedCount}</span>
</span> </span>
</div> </div>
@ -3661,12 +3675,12 @@ export default function App() {
</span> </span>
<span className={headerStatBadgeClass} title="Fav online"> <span className={headerStatBadgeClass} title="Fav online">
<HeartIcon className="size-4 opacity-80" /> <StarIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineFavCount}</span> <span className="tabular-nums">{onlineFavCount}</span>
</span> </span>
<span className={headerStatBadgeClass} title="Like online"> <span className={headerStatBadgeClass} title="Like online">
<HandThumbUpIcon className="size-4 opacity-80" /> <HeartIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineLikedCount}</span> <span className="tabular-nums">{onlineLikedCount}</span>
</span> </span>
</div> </div>

View File

@ -26,7 +26,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
} }
const base = const base =
'inline-flex items-center justify-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed' 'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
const roundedMap = { const roundedMap = {
sm: 'rounded-sm', sm: 'rounded-sm',
@ -47,11 +47,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' + '!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500', 'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft: soft:
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' + 'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30', 'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30',
}, },
blue: { blue: {
@ -59,11 +59,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' + '!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500', 'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft: soft:
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' + 'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30', 'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30',
}, },
emerald: { emerald: {
@ -71,11 +71,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' + '!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500', 'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft: soft:
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' + 'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30', 'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30',
}, },
red: { red: {
@ -83,11 +83,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' + '!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500', 'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft: soft:
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' + 'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30', 'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30',
}, },
amber: { amber: {
@ -95,11 +95,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' + '!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500', 'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary: secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' + 'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20', 'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft: soft:
'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' + 'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30', 'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30',
}, },
} }

View File

@ -7,9 +7,9 @@ type Size = 'sm' | 'md' | 'lg'
export type ButtonGroupItem = { export type ButtonGroupItem = {
id: string id: string
label?: React.ReactNode // optional (für icon-only) label?: React.ReactNode
icon?: React.ReactNode icon?: React.ReactNode
srLabel?: string // für icon-only (Screenreader) srLabel?: string
disabled?: boolean disabled?: boolean
} }
@ -45,7 +45,7 @@ export default function ButtonGroup({
return ( return (
<span <span
className={cn( className={cn(
'isolate inline-flex rounded-md shadow-xs dark:shadow-none ring-1 ring-gray-300 dark:ring-gray-700 overflow-hidden', 'isolate inline-flex overflow-hidden rounded-md shadow-xs ring-1 ring-gray-300 dark:shadow-none dark:ring-gray-700',
className className
)} )}
role="group" role="group"
@ -65,20 +65,28 @@ export default function ButtonGroup({
onClick={() => onChange(it.id)} onClick={() => onChange(it.id)}
aria-pressed={active} aria-pressed={active}
className={cn( className={cn(
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors', 'relative inline-flex items-center justify-center font-semibold leading-none',
!isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700', 'transition-[background-color,color,box-shadow] duration-150',
'focus:z-20 hover:z-10',
!isFirst && 'before:absolute before:bottom-0 before:left-0 before:top-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
isFirst && 'rounded-l-md', isFirst && 'rounded-l-md',
isLast && 'rounded-r-md', isLast && 'rounded-r-md',
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
active active
? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50' ? [
: 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20', 'bg-indigo-100 text-indigo-800',
'hover:bg-indigo-200',
'hover:shadow-[inset_0_0_0_1px_rgba(99,102,241,0.28),inset_0_0_0_9999px_rgba(99,102,241,0.10)]',
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50 dark:hover:shadow-none',
].join(' ')
: [
'bg-white text-gray-900',
'hover:bg-gray-50',
'hover:shadow-[inset_0_0_0_1px_rgba(148,163,184,0.35),inset_0_0_0_9999px_rgba(15,23,42,0.05)]',
'dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:hover:shadow-none',
].join(' '),
// Disabled 'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
'disabled:opacity-50 disabled:cursor-not-allowed',
// Padding / Größe
iconOnly ? `p-0 ${s.iconOnly}` : s.btn iconOnly ? `p-0 ${s.iconOnly}` : s.btn
)} )}
title={typeof it.label === 'string' ? it.label : it.srLabel} title={typeof it.label === 'string' ? it.label : it.srLabel}
@ -88,9 +96,11 @@ export default function ButtonGroup({
{it.icon ? ( {it.icon ? (
<span <span
className={cn( className={cn(
'shrink-0', 'shrink-0 transition-colors duration-150',
iconOnly ? '' : '-ml-0.5', iconOnly ? '' : '-ml-0.5',
active ? 'text-indigo-600 dark:text-indigo-200' : 'text-gray-400 dark:text-gray-500' active
? 'text-indigo-700 dark:text-indigo-200'
: 'text-gray-500 dark:text-gray-500'
)} )}
> >
{it.icon} {it.icon}
@ -103,4 +113,4 @@ export default function ButtonGroup({
})} })}
</span> </span>
) )
} }

View File

@ -7,11 +7,13 @@ import Table, { type Column } from './Table'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
import ModelPreview from './ModelPreview' import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar' import ProgressBar from './ProgressBar'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { useMediaQuery } from '../../lib/useMediaQuery' import { useMediaQuery } from '../../lib/useMediaQuery'
import DownloadsCardRow, { import DownloadsCardRow, {
PendingPreviewImage,
type DownloadRow, type DownloadRow,
type PendingWatchedRoom, type PendingWatchedRoom,
} from './DownloadsCardRow' } from './DownloadsCardRow'
@ -38,6 +40,9 @@ type Props = {
watching?: boolean watching?: boolean
roomStatus?: string roomStatus?: string
imageUrl?: string imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
} }
> >
roomStatusByModelKey?: Record<string, string> roomStatusByModelKey?: Record<string, string>
@ -56,6 +61,31 @@ type Props = {
maxConcurrentDownloads?: number maxConcurrentDownloads?: number
} }
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): boolean => {
const anyJ = job as any
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
return (
src.includes('chaturbate.com') ||
chat.includes('chaturbate.com') ||
img.includes('highwebmedia') ||
img.includes('chaturbate')
)
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
if (!isChaturbateJob(job, modelsByKey)) return ''
return onlineImageUrlOfJob(job, modelsByKey)
}
const pendingModelName = (p: PendingWatchedRoom) => { const pendingModelName = (p: PendingWatchedRoom) => {
const anyP = p as any const anyP = p as any
return ( return (
@ -68,13 +98,6 @@ const pendingModelName = (p: PendingWatchedRoom) => {
) )
} }
const shouldRefreshPreview = (j: RecordJob) => {
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
const status = String(j.status ?? '').trim().toLowerCase()
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
}
const pendingUrl = (p: PendingWatchedRoom) => { const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? '' return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
@ -588,8 +611,6 @@ export default function Downloads({
const [watchedBusy, setWatchedBusy] = useState(false) const [watchedBusy, setWatchedBusy] = useState(false)
const [thumbTick, setThumbTick] = useState(0)
const refreshWatchedState = useCallback(async () => { const refreshWatchedState = useCallback(async () => {
try { try {
await onRefreshAutostartState?.() await onRefreshAutostartState?.()
@ -598,33 +619,6 @@ export default function Downloads({
} }
}, [onRefreshAutostartState]) }, [onRefreshAutostartState])
const [pageVisible, setPageVisible] = useState(!document.hidden)
useEffect(() => {
const onVis = () => setPageVisible(!document.hidden)
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
const activePreviewJobs = jobs.filter((j) => {
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
const status = String(j.status ?? '').trim().toLowerCase()
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
}).length
if (activePreviewJobs === 0 || !pageVisible) return
const intervalMs = activePreviewJobs > 2 ? 30000 : 10000
const id = window.setInterval(() => {
setThumbTick((t) => t + 1)
}, intervalMs)
return () => window.clearInterval(id)
}, [jobs, pageVisible])
useEffect(() => { useEffect(() => {
const nextPaused = Boolean(autostartState?.paused) const nextPaused = Boolean(autostartState?.paused)
const nextPausedByUser = Boolean(autostartState?.pausedByUser) const nextPausedByUser = Boolean(autostartState?.pausedByUser)
@ -971,8 +965,7 @@ export default function Downloads({
cell: (r) => { cell: (r) => {
if (r.kind === 'job') { if (r.kind === 'job') {
const j = r.job const j = r.job
const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1` const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey)
return ( return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md"> <div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
@ -982,8 +975,7 @@ export default function Downloads({
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
initialSrc={onlineImageUrl || providerFallbackSrc} initialSrc={directPreviewSrc || undefined}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
enableHoverLive={true} enableHoverLive={true}
blur={blurPreviews} blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob( roomStatus={effectiveRoomStatusOfJob(
@ -992,7 +984,7 @@ export default function Downloads({
modelsByKey, modelsByKey,
growingByJobId growingByJobId
)} )}
fallbackSrc={providerFallbackSrc} fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full" className="w-full h-full"
/> />
</LazyMountWhenVisible> </LazyMountWhenVisible>
@ -1007,18 +999,11 @@ export default function Downloads({
if (img) { if (img) {
return ( return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10"> <div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
<img <PendingPreviewImage
src={img} src={img}
alt={name} alt={name}
className={[ blur={blurPreviews}
'h-full w-full object-cover', className="h-full w-full"
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
/> />
</div> </div>
) )
@ -1040,6 +1025,8 @@ export default function Downloads({
const j = r.job const j = r.job
const f = baseName(j.output || '') const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output) const name = modelNameFromOutput(j.output)
const modelKey = modelKeyFromJob(j)
const modelMeta = modelKey ? modelsByKey[modelKey] : undefined
const roomStatus = effectiveRoomStatusOfJob( const roomStatus = effectiveRoomStatusOfJob(
j, j,
@ -1054,6 +1041,14 @@ export default function Downloads({
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}> <span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name} {name}
</span> </span>
<ModelGenderIcon
gender={modelMeta?.gender}
sex={modelMeta?.sex}
subgender={modelMeta?.subgender}
className="shrink-0"
/>
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
@ -1091,11 +1086,24 @@ export default function Downloads({
const name = pendingModelName(p) const name = pendingModelName(p)
const url = pendingUrl(p) const url = pendingUrl(p)
const pendingKey = String((p as any)?.modelKey ?? name).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
return ( return (
<> <>
<span className="block max-w-[170px] truncate font-medium" title={name}> <div className="flex min-w-0 items-center gap-2">
{name} <span className="block max-w-[170px] truncate font-medium" title={name}>
</span> {name}
</span>
<ModelGenderIcon
gender={pendingFlags?.gender}
sex={pendingFlags?.sex}
subgender={pendingFlags?.subgender}
className="shrink-0"
/>
</div>
{url ? ( {url ? (
<a <a
href={url} href={url}
@ -1177,11 +1185,48 @@ export default function Downloads({
widthClassName: 'w-[320px] min-w-[300px]', widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => { cell: (r) => {
if (r.kind !== 'job') { if (r.kind !== 'job') {
const pendingKey = pendingRowKey(r.pending) const pendingKey = String(
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey]) (r.pending as any)?.modelKey ??
pendingModelName(r.pending)
).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
const pendingActionJob = {
id: `pending:${pendingKey || pendingRowKey(r.pending)}`,
output: `${pendingModelName(r.pending)}_pending.mp4`,
sourceUrl: pendingUrl(r.pending),
status: 'pending',
endedAt: null,
} as unknown as RecordJob
const isFav = Boolean(pendingFlags?.favorite)
const isLiked = pendingFlags?.liked === true
const isWatching = Boolean(pendingFlags?.watching)
const removeKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[removeKey])
return ( return (
<div className="flex justify-end pr-2"> <div
className="flex items-center justify-end gap-2 pr-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={pendingActionJob}
variant="table"
busy={false}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
<Button <Button
size="sm" size="sm"
variant="primary" variant="primary"
@ -1192,11 +1237,11 @@ export default function Downloads({
e.stopPropagation() e.stopPropagation()
if (isRemovingPending) return if (isRemovingPending) return
markRemovePendingRequested(pendingKey) markRemovePendingRequested(removeKey)
try { try {
await onRemovePending?.(r.pending) await onRemovePending?.(r.pending)
} catch { } catch {
clearRemovePendingRequested(pendingKey) clearRemovePendingRequested(removeKey)
} }
}} }}
> >
@ -1634,7 +1679,6 @@ export default function Downloads({
<DownloadsCardRow <DownloadsCardRow
r={r} r={r}
nowMs={nowMs} nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}
@ -1692,7 +1736,6 @@ export default function Downloads({
<DownloadsCardRow <DownloadsCardRow
r={r} r={r}
nowMs={nowMs} nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}
@ -1774,7 +1817,6 @@ export default function Downloads({
<DownloadsCardRow <DownloadsCardRow
r={r} r={r}
nowMs={nowMs} nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey} roomStatusByModelKey={roomStatusByModelKey}

View File

@ -2,8 +2,10 @@
'use client' 'use client'
import { useMemo, useState, useRef, useEffect } from 'react'
import Button from './Button' import Button from './Button'
import ModelPreview from './ModelPreview' import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import ProgressBar from './ProgressBar' import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
@ -31,12 +33,14 @@ type ModelFlags = {
watching?: boolean watching?: boolean
roomStatus?: string roomStatus?: string
imageUrl?: string imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
} }
type Props = { type Props = {
r: DownloadRow r: DownloadRow
nowMs: number nowMs: number
thumbTick?: number
blurPreviews?: boolean blurPreviews?: boolean
modelsByKey: Record<string, ModelFlags> modelsByKey: Record<string, ModelFlags>
roomStatusByModelKey: Record<string, string> roomStatusByModelKey: Record<string, string>
@ -126,6 +130,31 @@ const modelKeyFromJob = (job: RecordJob): string => {
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase() return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
} }
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): boolean => {
const anyJ = job as any
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
return (
src.includes('chaturbate.com') ||
chat.includes('chaturbate.com') ||
img.includes('highwebmedia') ||
img.includes('chaturbate')
)
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
if (!isChaturbateJob(job, modelsByKey)) return ''
return onlineImageUrlOfJob(job, modelsByKey)
}
const onlineImageUrlOfJob = ( const onlineImageUrlOfJob = (
job: RecordJob, job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }> modelsByKey?: Record<string, { imageUrl?: string }>
@ -441,10 +470,96 @@ function postWorkLabel(
return 'Warte auf Nacharbeiten…' return 'Warte auf Nacharbeiten…'
} }
export function PendingPreviewImage({
src,
alt,
blur,
className,
}: {
src: string
alt: string
blur?: boolean
className?: string
}) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [nonce, setNonce] = useState(0)
const [imgError, setImgError] = useState(false)
useEffect(() => {
const el = rootRef.current
if (!el) return
const obs = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
},
{ root: null, threshold: 0.01 }
)
obs.observe(el)
return () => obs.disconnect()
}, [])
useEffect(() => {
if (!inView) return
const id = window.setInterval(() => {
setNonce((n) => n + 1)
}, 20000)
return () => window.clearInterval(id)
}, [inView, src])
useEffect(() => {
setImgError(false)
}, [src])
const finalSrc = useMemo(() => {
if (!inView) return ''
const s = String(src ?? '').trim()
if (!s) return ''
return s.includes('?') ? `${s}&v=${nonce}` : `${s}?v=${nonce}`
}, [src, nonce, inView])
return (
<div
ref={rootRef}
className={className}
>
{inView && !imgError && finalSrc ? (
<img
src={finalSrc}
alt={alt}
className={[
'block h-full w-full object-cover',
blur ? 'blur-md' : '',
].join(' ')}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setImgError(true)}
/>
) : (
<div
className={[
'grid h-full w-full place-items-center bg-gray-100 text-sm text-gray-500 dark:bg-white/10 dark:text-gray-300',
blur ? 'blur-md' : '',
].join(' ')}
>
</div>
)}
</div>
)
}
export default function DownloadsCardRow({ export default function DownloadsCardRow({
r, r,
nowMs, nowMs,
thumbTick,
blurPreviews, blurPreviews,
modelsByKey, modelsByKey,
roomStatusByModelKey, roomStatusByModelKey,
@ -464,7 +579,25 @@ export default function DownloadsCardRow({
const name = pendingModelName(p) const name = pendingModelName(p)
const url = pendingUrl(p) const url = pendingUrl(p)
const show = normalizeRoomStatus(p.currentShow) const show = normalizeRoomStatus(p.currentShow)
const img = pendingImageUrl(p)
const pendingKey = String(p.modelKey ?? name).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
const img =
pendingImageUrl(p) ||
String(pendingFlags?.imageUrl ?? '').trim()
const pendingActionJob = {
id: `pending:${pendingKey || name}`,
output: `${name}_pending.mp4`,
sourceUrl: url,
status: 'pending',
endedAt: null,
} as unknown as RecordJob
const isFav = Boolean(pendingFlags?.favorite)
const isLiked = pendingFlags?.liked === true
const isWatching = Boolean(pendingFlags?.watching)
return ( return (
<div <div
@ -483,26 +616,18 @@ export default function DownloadsCardRow({
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className=" className="
relative relative shrink-0 overflow-hidden rounded-lg
shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px] w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5 bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10 dark:bg-white/10 dark:ring-white/10
" "
> >
{img ? ( {img ? (
<img <PendingPreviewImage
src={img} src={img}
alt={name} alt={name}
className={[ blur={blurPreviews}
'h-full w-full object-cover', className="h-full w-full"
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
/> />
) : ( ) : (
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300"> <div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
@ -512,35 +637,38 @@ export default function DownloadsCardRow({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-w-0 items-center gap-1.5">
<div <div
className="truncate text-base font-semibold text-gray-900 dark:text-white" className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
title={name} title={name}
> >
{name} {name}
</div> </div>
<span <ModelGenderIcon
className={[ gender={pendingFlags?.gender}
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', sex={pendingFlags?.sex}
roomStatusTone(show), subgender={pendingFlags?.subgender}
].join(' ')} className="shrink-0"
title={show} />
>
{show}
</span>
</div> </div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300"> <div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Wartend auf Autostart Warte auf Online-Status
</div> </div>
</div> </div>
<div className="shrink-0 flex flex-col items-end gap-1"> <div className="shrink-0 flex items-start">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200"> <span
className={[
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(show),
].join(' ')}
title={show}
>
{show}
</span> </span>
</div> </div>
</div> </div>
@ -560,32 +688,47 @@ export default function DownloadsCardRow({
</div> </div>
</div> </div>
<div className="mt-3"> <div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
<ProgressBar <div
label="Warte auf Online-Status…" className="flex items-center gap-1"
indeterminate onClick={(e) => e.stopPropagation()}
size="sm" onMouseDown={(e) => e.stopPropagation()}
className="w-full" >
/> <RecordJobActions
</div> job={pendingActionJob}
variant="table"
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10"> busy={false}
<div className="flex items-center gap-1 opacity-60"> isFavorite={isFav}
<span className="text-xs text-gray-500 dark:text-gray-400"> isLiked={isLiked}
Wird automatisch gestartet, sobald das Model public ist. isWatching={isWatching}
</span> onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
</div> </div>
<div className="hidden sm:block"> <div
<Button className="ml-auto flex items-center justify-end gap-2"
size="sm" onClick={(e) => e.stopPropagation()}
variant="primary" onMouseDown={(e) => e.stopPropagation()}
color="red" >
className="shrink-0" <span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
disabled
>
Wartend Wartend
</Button> </span>
<div className="hidden sm:block">
<Button
size="sm"
variant="primary"
color="red"
className="shrink-0"
disabled
>
Wartend
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -654,18 +797,12 @@ export default function DownloadsCardRow({
Boolean(phase) && Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100) (!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : '' const modelKey = modelKeyFromJob(j)
const flags = key ? modelsByKey[key] : undefined const flags = modelKey ? modelsByKey[modelKey] : undefined
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching) const isWatching = Boolean(flags?.watching)
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
const shouldRefreshPreview = (j: RecordJob) => {
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
const status = String(j.status ?? '').trim().toLowerCase()
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
}
return ( return (
<div <div
@ -703,11 +840,7 @@ export default function DownloadsCardRow({
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
initialSrc={ initialSrc={directPreviewSrc || undefined}
onlineImageUrlOfJob(j, modelsByKey) ||
`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
enableHoverLive={false} enableHoverLive={false}
blur={blurPreviews} blur={blurPreviews}
roomStatus={previewRoomStatusOfJob( roomStatus={previewRoomStatusOfJob(
@ -723,33 +856,41 @@ export default function DownloadsCardRow({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex min-w-0 items-center gap-1.5">
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}> <div
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
title={name}
>
{name} {name}
</div> </div>
<span
className={[ <ModelGenderIcon
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', gender={flags?.gender}
roomStatusTone(roomStatus), sex={flags?.sex}
].join(' ')} subgender={flags?.subgender}
title={roomStatus} className="shrink-0"
> />
{roomStatus}
</span>
</div> </div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
<div
className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300"
title={j.output}
>
{file || '—'} {file || '—'}
</div> </div>
</div> </div>
<div className="shrink-0 flex flex-col items-end gap-1"> <div className="shrink-0 flex items-start">
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200"> <span
{runtimeOf(j, nowMs)} className={[
</span> 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200"> roomStatusTone(roomStatus),
{formatBytes(sizeBytesOf(j))} ].join(' ')}
title={roomStatus}
>
{roomStatus}
</span> </span>
</div> </div>
</div> </div>
@ -790,7 +931,7 @@ export default function DownloadsCardRow({
</div> </div>
) : null} ) : null}
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10"> <div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
<div <div
className="flex items-center gap-1" className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -811,28 +952,43 @@ export default function DownloadsCardRow({
/> />
</div> </div>
<div className="hidden sm:block"> <div
<Button className="ml-auto flex min-w-0 self-center items-center justify-end gap-2"
size="sm" onClick={(e) => e.stopPropagation()}
variant="primary" onMouseDown={(e) => e.stopPropagation()}
color={isQueuedPostwork ? 'red' : 'indigo'} >
disabled={disableStopButton} <div className="flex items-center justify-end gap-2 text-right">
className="shrink-0" <span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
onClick={async (e) => { {runtimeOf(j, nowMs)}
e.stopPropagation() </span>
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
{formatBytes(sizeBytesOf(j))}
</span>
</div>
if (showRemoveQueuedButton) { <div className="hidden sm:block">
await onRemoveQueuedPostworkJob?.(j.id) <Button
return size="sm"
} variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (isStopping) return if (showRemoveQueuedButton) {
markStopRequested(j.id) await onRemoveQueuedPostworkJob?.(j.id)
await onStopJob(j.id) return
}} }
>
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} if (isStopping) return
</Button> markStopRequested(j.id)
await onStopJob(j.id)
}}
>
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ import {
readPreviewSpriteInfo, readPreviewSpriteInfo,
scrubProgressRatioFromIndex, scrubProgressRatioFromIndex,
} from './previewSprite' } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -536,13 +537,6 @@ export default function FinishedDownloadsCardsView({
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop') const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
const previewInstanceKey = [
k,
previewRenderRole,
inlineActive ? 'inline' : 'teaser',
allowTeaserAnimation ? 'anim' : 'still',
].join('::')
const showMobileAudioButton = const showMobileAudioButton =
Boolean(onUnlockMobileTeaserAudio) && Boolean(onUnlockMobileTeaserAudio) &&
isMobileTopCard && isMobileTopCard &&
@ -700,7 +694,6 @@ export default function FinishedDownloadsCardsView({
) : null} ) : null}
<FinishedVideoPreview <FinishedVideoPreview
key={previewInstanceKey}
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full" className="h-full w-full"
@ -859,8 +852,17 @@ export default function FinishedDownloadsCardsView({
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2 min-w-0"> <div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 flex items-center gap-1.5">
{model} <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
</div> </div>
{renderPostworkBadge && livePostwork ? ( {renderPostworkBadge && livePostwork ? (

View File

@ -27,6 +27,7 @@ import {
readPreviewSpriteInfo, readPreviewSpriteInfo,
scrubProgressRatioFromIndex, scrubProgressRatioFromIndex,
} from './previewSprite' } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
function normalizeDurationSeconds(value: unknown): number | undefined { function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -48,7 +49,7 @@ type Props = {
) => FinishedPostworkSummary | undefined ) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
durations: Record<string, number> durationSeconds?: number
teaserState: FinishedDownloadsTeaserState teaserState: FinishedDownloadsTeaserState
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
@ -100,7 +101,7 @@ function FinishedDownloadsGalleryCardInner({
postworkByFile, postworkByFile,
getPostworkSummaryForOutput, getPostworkSummaryForOutput,
renderPostworkBadge, renderPostworkBadge,
durations, durationSeconds,
teaserState, teaserState,
keyFor, keyFor,
baseName, baseName,
@ -261,7 +262,7 @@ function FinishedDownloadsGalleryCardInner({
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds = const previewDurationSeconds =
durations[k] ?? durationSeconds ??
normalizeDurationSeconds(meta?.media?.durationSeconds) ?? normalizeDurationSeconds(meta?.media?.durationSeconds) ??
normalizeDurationSeconds(meta?.file?.durationSeconds) normalizeDurationSeconds(meta?.file?.durationSeconds)
@ -336,7 +337,6 @@ function FinishedDownloadsGalleryCardInner({
<div className="absolute inset-0 overflow-hidden rounded-t-lg"> <div className="absolute inset-0 overflow-hidden rounded-t-lg">
<div className="absolute inset-0"> <div className="absolute inset-0">
<FinishedVideoPreview <FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j} job={j}
getFileName={(p) => stripHotPrefix(baseName(p))} getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds} durationSeconds={previewDurationSeconds}
@ -442,8 +442,17 @@ function FinishedDownloadsGalleryCardInner({
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0"> <div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}> <div className="min-w-0 flex items-center gap-1.5">
{model} <div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
{model}
</div>
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
</div> </div>
{showPostworkBadge && renderPostworkBadge ? ( {showPostworkBadge && renderPostworkBadge ? (

View File

@ -219,6 +219,7 @@ export default function FinishedDownloadsGalleryView({
> >
{rows.map((j) => { {rows.map((j) => {
const k = keyFor(j) const k = keyFor(j)
const durationForRow = durations[k]
return ( return (
<FinishedDownloadsGalleryCard <FinishedDownloadsGalleryCard
@ -232,7 +233,7 @@ export default function FinishedDownloadsGalleryView({
postworkByFile={postworkByFile} postworkByFile={postworkByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput} getPostworkSummaryForOutput={getPostworkSummaryForOutput}
renderPostworkBadge={renderPostworkBadge} renderPostworkBadge={renderPostworkBadge}
durations={durations} durationSeconds={durationForRow}
teaserState={teaserState} teaserState={teaserState}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}

View File

@ -22,6 +22,7 @@ import {
} from './teaserPlayback' } from './teaserPlayback'
import Checkbox from './Checkbox' import Checkbox from './Checkbox'
import { parseJobMeta } from './previewSprite' import { parseJobMeta } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -365,8 +366,17 @@ export default function FinishedDownloadsTableView({
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="min-w-0 flex items-center gap-1.5">
{model} <div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{model}
</div>
<ModelGenderIcon
gender={modelsByKey[modelKey]?.gender}
sex={modelsByKey[modelKey]?.sex}
subgender={modelsByKey[modelKey]?.subgender}
className="shrink-0"
/>
</div> </div>
{showPostworkBadge && renderPostworkBadge ? ( {showPostworkBadge && renderPostworkBadge ? (

View File

@ -685,8 +685,8 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}` return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
}, [previewId, v]) }, [previewId])
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =

View File

@ -377,6 +377,34 @@ function pill(cls: string) {
) )
} }
function heroOverlayPill(cls: string) {
return cn(
'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold text-white',
'ring-1 backdrop-blur-md shadow-sm',
'[text-shadow:0_1px_1px_rgba(0,0,0,0.45)]',
cls
)
}
function heroStatusPill(show: string) {
switch (show) {
case 'public':
return heroOverlayPill('bg-emerald-500/85 ring-emerald-300/25')
case 'private':
return heroOverlayPill('bg-fuchsia-500/85 ring-fuchsia-300/25')
case 'hidden':
return heroOverlayPill('bg-slate-900/70 ring-white/15')
case 'away':
return heroOverlayPill('bg-amber-500/85 ring-amber-300/25')
default:
return heroOverlayPill('bg-black/55 ring-white/15')
}
}
function heroNewPill() {
return heroOverlayPill('bg-amber-500/90 ring-amber-300/25')
}
const previewBlurCls = (blur?: boolean) => const previewBlurCls = (blur?: boolean) =>
blur ? 'blur-md scale-[1.03] brightness-90' : '' blur ? 'blur-md scale-[1.03] brightness-90' : ''
@ -1400,30 +1428,6 @@ export default function ModelDetails({
/> />
</div> </div>
)} )}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{showPill ? (
<span
className={pill(
showLabel === 'public'
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15'
)}
>
{showPill}
</span>
) : null}
{effectiveRoom?.is_new ? (
<span
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/20'
)}
>
NEW
</span>
) : null}
</div>
</div> </div>
} }
width="max-w-6xl" width="max-w-6xl"
@ -1481,25 +1485,15 @@ export default function ModelDetails({
/> />
{/* Pills */} {/* Pills */}
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2"> <div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
{showPill ? ( {showPill ? (
<span <span className={heroStatusPill(showLabel)}>
className={pill(
showLabel === 'public'
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15'
)}
>
{showPill} {showPill}
</span> </span>
) : null} ) : null}
{effectiveRoom?.is_new ? ( {effectiveRoom?.is_new ? (
<span <span className={heroNewPill()}>
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
)}
>
NEW NEW
</span> </span>
) : null} ) : null}
@ -1870,9 +1864,33 @@ export default function ModelDetails({
{/* Top row: name/username links + action icons rechts */} {/* Top row: name/username links + action icons rechts */}
<div className="absolute left-2 right-2 top-2 z-20 flex items-start justify-between gap-3"> <div className="absolute left-2 right-2 top-2 z-20 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 pr-2"> <div className="min-w-0 flex-1 pr-2">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center">
<div className="truncate text-sm font-semibold text-white drop-shadow"> <div className="inline-flex min-w-0 max-w-full items-center gap-1.5">
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'} <span className="min-w-0 truncate text-sm font-semibold text-white drop-shadow">
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
</span>
{showPill ? (
<span
className={cn(
heroStatusPill(showLabel),
'shrink-0 px-2 py-0.5 text-[10px] shadow-md'
)}
>
{showPill}
</span>
) : null}
{effectiveRoom?.is_new ? (
<span
className={cn(
heroNewPill(),
'shrink-0 px-2 py-0.5 text-[10px] shadow-md'
)}
>
NEW
</span>
) : null}
</div> </div>
</div> </div>

View File

@ -19,15 +19,14 @@ function normalizeGender(
if (!raw) return null if (!raw) return null
if (raw.includes('couple') || raw.includes('pair')) { if (raw.includes('couple') || raw.includes('pair') || raw === 'c') {
return 'couple' return 'couple'
} }
if ( if (
raw.includes('trans') || raw.includes('trans') ||
raw.includes('transgender') || raw.includes('transgender') ||
raw.includes('ts') || raw === 't'
raw.includes('shemale')
) { ) {
return 'trans' return 'trans'
} }
@ -35,7 +34,9 @@ function normalizeGender(
if ( if (
raw.includes('female') || raw.includes('female') ||
raw.includes('girl') || raw.includes('girl') ||
raw.includes('woman') raw.includes('woman') ||
raw === 'f' ||
raw === 'w'
) { ) {
return 'female' return 'female'
} }
@ -43,7 +44,8 @@ function normalizeGender(
if ( if (
raw.includes('male') || raw.includes('male') ||
raw.includes('boy') || raw.includes('boy') ||
raw.includes('man') raw.includes('man') ||
raw === 'm'
) { ) {
return 'male' return 'male'
} }
@ -153,13 +155,22 @@ export default function ModelGenderIcon({
const { label, Icon } = map[kind] const { label, Icon } = map[kind]
const toneClass =
kind === 'female'
? 'bg-pink-100 text-pink-700 ring-pink-200 dark:bg-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25'
: kind === 'male'
? 'bg-sky-100 text-sky-700 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-200 dark:ring-sky-400/25'
: kind === 'trans'
? 'bg-violet-100 text-violet-700 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/25'
: 'bg-gradient-to-r from-sky-100 to-pink-100 text-fuchsia-700 ring-fuchsia-200 dark:from-sky-500/15 dark:to-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25'
return ( return (
<span <span
className={cn( className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset', 'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
variant === 'hero' variant === 'hero'
? 'size-5 bg-black/30 text-white ring-white/20 backdrop-blur' ? cn('size-5 backdrop-blur shadow-sm', toneClass)
: 'size-5 bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15', : cn('size-5', toneClass),
className className
)} )}
title={label} title={label}

View File

@ -57,6 +57,9 @@ export default function ModelPreview({
const [liveSrc, setLiveSrc] = useState('') const [liveSrc, setLiveSrc] = useState('')
const [hoverOpen, setHoverOpen] = useState(false) const [hoverOpen, setHoverOpen] = useState(false)
const [localRefreshNonce, setLocalRefreshNonce] = useState(0)
const [usingFallback, setUsingFallback] = useState(false)
const [livePreparing, setLivePreparing] = useState(false) const [livePreparing, setLivePreparing] = useState(false)
const [liveReady, setLiveReady] = useState(false) const [liveReady, setLiveReady] = useState(false)
@ -84,24 +87,49 @@ export default function ModelPreview({
const previewSrc = useMemo(() => { const previewSrc = useMemo(() => {
const explicit = String(initialSrc ?? '').trim() const explicit = String(initialSrc ?? '').trim()
if (explicit) { const base = explicit || `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
return explicit
}
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` const version =
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
? `${refreshKey}-${localRefreshNonce}`
: localRefreshNonce > 0
? String(localRefreshNonce)
: ''
if (typeof refreshKey === 'number') { if (!version) return base
return `${base}&v=${refreshKey}`
}
return base return base.includes('?')
}, [initialSrc, jobId, refreshKey]) ? `${base}&v=${encodeURIComponent(version)}`
: `${base}?v=${encodeURIComponent(version)}`
}, [initialSrc, jobId, refreshKey, localRefreshNonce])
const fallbackImgSrc = useMemo(() => { const fallbackImgSrc = useMemo(() => {
const s = String(fallbackSrc ?? '').trim() const s = String(fallbackSrc ?? '').trim()
if (s) return s const base = s || `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
}, [fallbackSrc, jobId]) const version =
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
? `${refreshKey}-${localRefreshNonce}`
: localRefreshNonce > 0
? String(localRefreshNonce)
: ''
if (!version) return base
return base.includes('?')
? `${base}&v=${encodeURIComponent(version)}`
: `${base}?v=${encodeURIComponent(version)}`
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
useEffect(() => {
if (!inView) return
const id = window.setInterval(() => {
setLocalRefreshNonce((n) => n + 1)
}, 20000)
return () => window.clearInterval(id)
}, [inView, jobId])
useEffect(() => { useEffect(() => {
const ac = new AbortController() const ac = new AbortController()
@ -170,12 +198,21 @@ export default function ModelPreview({
useEffect(() => { useEffect(() => {
setImgError(false) setImgError(false)
setUsingFallback(false)
setImgSrc(previewSrc) setImgSrc(previewSrc)
setLiveSrc('') setLiveSrc('')
setLivePreparing(false) setLivePreparing(false)
setLiveReady(false) setLiveReady(false)
}, [jobId, previewSrc]) }, [jobId, previewSrc])
useEffect(() => {
if (!usingFallback) return
if (!fallbackImgSrc) return
setImgError(false)
setImgSrc(fallbackImgSrc)
}, [usingFallback, fallbackImgSrc])
return ( return (
<HoverPopover <HoverPopover
onOpenChange={(open) => { onOpenChange={(open) => {
@ -287,21 +324,26 @@ export default function ModelPreview({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
alt="" alt=""
referrerPolicy="no-referrer"
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')} className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => { onLoad={() => {
setImgError(false) setImgError(false)
const current = String(imgSrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim()
setUsingFallback(Boolean(fallback && current === fallback))
}} }}
onError={() => { onError={() => {
const current = String(imgSrc || '').trim() const current = String(imgSrc || '').trim()
const fallback = String(fallbackImgSrc || '').trim() const fallback = String(fallbackImgSrc || '').trim()
// Erst von preview.jpg auf Provider-Fallback wechseln
if (fallback && current !== fallback) { if (fallback && current !== fallback) {
setUsingFallback(true)
setImgError(false)
setImgSrc(fallback) setImgSrc(fallback)
return return
} }
// Wenn selbst das Fallback nicht lädt: endgültig Fehler
setImgError(true) setImgError(true)
}} }}
/> />

View File

@ -14,6 +14,7 @@ import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import TextInput from './TextInput' import TextInput from './TextInput'
import ModelGenderIcon from './ModelGenderIcon'
type ParsedModel = { type ParsedModel = {
input: string input: string
@ -51,6 +52,10 @@ export type StoredModel = {
profileImageCached?: string profileImageCached?: string
profileImageUpdatedAt?: string profileImageUpdatedAt?: string
gender?: string | null
sex?: string | null
subgender?: string | null
watching: boolean watching: boolean
favorite: boolean favorite: boolean
hot: boolean hot: boolean
@ -741,7 +746,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<div className="font-medium truncate">{m.modelKey}</div> <div className="flex items-center gap-1.5 min-w-0">
<div className="font-medium truncate">{m.modelKey}</div>
<ModelGenderIcon
gender={m.gender}
sex={m.sex}
subgender={m.subgender}
className="shrink-0"
/>
</div>
{href ? ( {href ? (
<span <span
className="shrink-0 text-xs text-gray-400 dark:text-gray-500" className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
@ -1320,11 +1334,20 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */} {/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5"> <div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
<div <div className="flex items-center gap-1.5 min-w-0">
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]" <div
title={m.modelKey} className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
> title={m.modelKey}
{m.modelKey} >
{m.modelKey}
</div>
<ModelGenderIcon
gender={m.gender}
sex={m.sex}
subgender={m.subgender}
className="shrink-0 text-white"
/>
</div> </div>
{m.host ? ( {m.host ? (
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"> <div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">

View File

@ -57,9 +57,9 @@ function PageButton({
className={clsx( className={clsx(
'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0', 'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
roundedCls, roundedCls,
disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer', disabled
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50', ? 'opacity-100 cursor-not-allowed bg-white text-gray-400 inset-ring inset-ring-gray-200 dark:bg-slate-900 dark:text-gray-500 dark:inset-ring-slate-800'
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700' : 'cursor-pointer bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
)} )}
> >
{children} {children}

View File

@ -120,6 +120,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null) const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const assetsAbortRef = useRef<AbortController | null>(null) const assetsAbortRef = useRef<AbortController | null>(null)
const assetsCancelRequestedRef = useRef(false)
const [dbModalOpen, setDbModalOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('') const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
@ -258,7 +259,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const assetsLabel = String(assets?.currentLabel || '').trim() const assetsLabel = String(assets?.currentLabel || '').trim()
const assetsPhase = String(assets?.currentPhase || '').trim() const assetsPhase = String(assets?.currentPhase || '').trim()
if (assets?.running) { if (assets?.queued) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: String(assets.text || 'Wartet…'),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else if (assets?.running) {
setAssetsTask((t) => ({ setAssetsTask((t) => ({
...t, ...t,
status: 'running', status: 'running',
@ -278,12 +291,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
assetsError.toLowerCase() === 'cancelled' || assetsError.toLowerCase() === 'cancelled' ||
assetsError.toLowerCase() === 'canceled' assetsError.toLowerCase() === 'canceled'
if (cancelled && assetsCancelRequestedRef.current) {
return
}
setAssetsTask((t) => { setAssetsTask((t) => {
if (cancelled && (t.status === 'idle' || t.fading)) { if (cancelled && (t.status === 'idle' || t.fading)) {
return t return t
} }
const next: TaskItem = { return {
...t, ...t,
status: cancelled ? 'cancelled' : 'error', status: cancelled ? 'cancelled' : 'error',
title: 'Assets generieren', title: 'Assets generieren',
@ -294,10 +311,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
cancellable: false, cancellable: false,
fading: t.fading ?? false, fading: t.fading ?? false,
} }
return next
}) })
} else if (assets?.text || assets?.finishedAt) { } else if (assets?.finishedAt) {
setAssetsTask((t) => ({ setAssetsTask((t) => ({
...t, ...t,
status: 'done', status: 'done',
@ -654,7 +669,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) { function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) {
window.setTimeout(() => { window.setTimeout(() => {
setter((t) => ({ ...t, fading: true })) setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => { window.setTimeout(() => {
@ -672,7 +687,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}, delayMs) }, delayMs)
} }
function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) { function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) {
const id = regenerateTaskId(file) const id = regenerateTaskId(file)
window.setTimeout(() => { window.setTimeout(() => {
@ -769,35 +784,48 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
async function cancelAssetsTask() { async function cancelAssetsTask() {
const ac = assetsAbortRef.current assetsCancelRequestedRef.current = true
assetsAbortRef.current = null
// UI sofort const ac = assetsAbortRef.current
setAssetsTask((t: TaskItem) => ({ assetsAbortRef.current = null
...t,
status: 'cancelled',
title: 'Assets generieren',
text: '',
err: undefined,
cancellable: false,
fading: false,
}))
fadeOutTask(setAssetsTask) // UI ruhig ausblenden statt "Abgebrochen" sichtbar zu zeigen
setAssetsTask((t: TaskItem) => ({
...t,
cancellable: false,
fading: true,
}))
if (ac) { window.setTimeout(() => {
ac.abort() setAssetsTask((t: TaskItem) => ({
return ...t,
status: 'idle',
title: 'Assets generieren',
text: '',
err: undefined,
done: 0,
total: 0,
cancellable: true,
fading: false,
startedAtMs: 0,
}))
}, 500)
if (ac) {
ac.abort()
return
}
try {
await fetch('/api/tasks/generate-assets', {
method: 'DELETE',
cache: 'no-store' as any,
})
} catch {
// ignore
}
} }
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
} catch {
// ignore
}
}
async function cancelRegenerateAssetsTask(file: string) { async function cancelRegenerateAssetsTask(file: string) {
const cleanFile = String(file || '').trim() const cleanFile = String(file || '').trim()
if (!cleanFile) return if (!cleanFile) return
@ -920,7 +948,23 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
stopUrl="/api/tasks/generate-assets" stopUrl="/api/tasks/generate-assets"
sseUrl="/api/events/stream" sseUrl="/api/events/stream"
onFinished={onAssetsGenerated} onFinished={onAssetsGenerated}
onQueued={() => {
assetsCancelRequestedRef.current = false
setAssetsTask((t: TaskItem) => ({
...t,
status: 'queued',
title: 'Assets generieren',
text: 'Wartet…',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
startedAtMs: t.startedAtMs || Date.now(),
}))
}}
onStart={(ac) => { onStart={(ac) => {
assetsCancelRequestedRef.current = false
assetsAbortRef.current = ac assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
@ -930,6 +974,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
done: 0, done: 0,
total: 0, total: 0,
err: undefined, err: undefined,
cancellable: true,
fading: false, fading: false,
startedAtMs: t.startedAtMs || Date.now(), startedAtMs: t.startedAtMs || Date.now(),
})) }))
@ -955,6 +1000,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}} }}
onCancelled={() => { onCancelled={() => {
assetsAbortRef.current = null assetsAbortRef.current = null
if (assetsCancelRequestedRef.current) {
assetsCancelRequestedRef.current = false
return
}
setAssetsTask((t: TaskItem) => ({ setAssetsTask((t: TaskItem) => ({
...t, ...t,
status: 'cancelled', status: 'cancelled',
@ -983,7 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
startingLabel="Läuft…" startingLabel="Läuft…"
onTrigger={cleanupSmallDone} onTrigger={cleanupSmallDone}
busy={cleaning} busy={cleaning}
disabled={saving || !value.autoDeleteSmallDownloads} disabled={saving}
onError={(message) => { onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task // Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
setErr(message) setErr(message)
@ -1117,7 +1168,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div> </div>
</div> </div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5"> <div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5 mb-2">
<LabeledSwitch <LabeledSwitch
checked={!!value.enableConcurrentDownloadsLimit} checked={!!value.enableConcurrentDownloadsLimit}
onChange={(checked) => onChange={(checked) =>

View File

@ -41,6 +41,7 @@ type Props = {
/** Callback-Hooks (nur für API/SSE-Variante relevant) */ /** Callback-Hooks (nur für API/SSE-Variante relevant) */
onFinished?: () => void onFinished?: () => void
onStart?: (ac: AbortController) => void onStart?: (ac: AbortController) => void
onQueued?: () => void
onProgress?: (p: Progress) => void onProgress?: (p: Progress) => void
onDone?: () => void onDone?: () => void
onCancelled?: () => void onCancelled?: () => void
@ -85,6 +86,7 @@ export default function Task({
busy = false, busy = false,
onFinished, onFinished,
onStart, onStart,
onQueued,
onProgress, onProgress,
onDone, onDone,
onCancelled, onCancelled,
@ -155,12 +157,13 @@ export default function Task({
if (prev && !cur) { if (prev && !cur) {
const errText = String(state?.error ?? '').trim() const errText = String(state?.error ?? '').trim()
const errTextNorm = errText.toLowerCase()
// Reset pro Run // Reset pro Run
abortRef.current = null abortRef.current = null
armedRef.current = false armedRef.current = false
if (cancelledRef.current || errText === 'abgebrochen') { if (cancelledRef.current || errTextNorm === 'abgebrochen') {
cancelledRef.current = false cancelledRef.current = false
onCancelled?.() onCancelled?.()
} else if (errText) { } else if (errText) {
@ -191,9 +194,10 @@ export default function Task({
} }
const errText = String(st?.error ?? '').trim() const errText = String(st?.error ?? '').trim()
const errTextNorm = errText.toLowerCase()
// Abbruch ist kein "Fehler"-Event für die UI // Abbruch ist kein "Fehler"-Event für die UI
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) { if (errText && errTextNorm !== 'abgebrochen' && errText !== lastErrorRef.current) {
lastErrorRef.current = errText lastErrorRef.current = errText
onErrorRef.current?.(errText) onErrorRef.current?.(errText)
} }
@ -205,13 +209,16 @@ export default function Task({
async function start() { async function start() {
if (busy) return if (busy) return
if (state?.running) return if (state?.running) return
if (starting) return
setStartError(null) setStartError(null)
setStarting(true) setStarting(true)
cancelledRef.current = false cancelledRef.current = false
lastErrorRef.current = '' lastErrorRef.current = ''
// 1) Lokaler Task-Modus (z.B. Cleanup) onQueued?.()
// 1) Lokaler Task-Modus
if (onTrigger) { if (onTrigger) {
try { try {
await onTrigger() await onTrigger()
@ -225,7 +232,7 @@ export default function Task({
return return
} }
// 2) API/SSE-Task-Modus (wie Assets) // 2) API/SSE-Task-Modus
if (!startUrl) { if (!startUrl) {
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).' const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
setStartError(msg) setStartError(msg)
@ -234,17 +241,16 @@ export default function Task({
return return
} }
// Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren
const ac = ensureControllerCreated() const ac = ensureControllerCreated()
try { try {
const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' }) const st = await fetchJSON<TaskState & { queued?: boolean }>(startUrl, { method: 'POST' })
setState(st) setState(st)
// TaskList jetzt aktivieren // Nur wenn der Task wirklich schon läuft
armTaskList(ac)
if (st?.running) { if (st?.running) {
armTaskList(ac)
onProgress?.({ onProgress?.({
done: st?.done ?? 0, done: st?.done ?? 0,
total: st?.total ?? 0, total: st?.total ?? 0,
@ -252,7 +258,6 @@ export default function Task({
}) })
} }
} catch (e: any) { } catch (e: any) {
// Start fehlgeschlagen -> Controller/Flags zurücksetzen
abortRef.current = null abortRef.current = null
armedRef.current = false armedRef.current = false

View File

@ -4,7 +4,7 @@
export type TaskItem = { export type TaskItem = {
id: string id: string
status: 'idle' | 'running' | 'done' | 'error' | 'cancelled' status: 'idle' | 'queued' | 'running' | 'done' | 'error' | 'cancelled'
title: string title: string
text?: string text?: string
done?: number done?: number
@ -60,26 +60,30 @@ export default function TaskList({ tasks, onCancel }: Props) {
const hasProgress = isRunning && (t.total ?? 0) > 0 const hasProgress = isRunning && (t.total ?? 0) > 0
const title = (t.title ?? '').trim() const title = (t.title ?? '').trim()
const suffix = (t.text ?? '').trim() const suffix = (t.text ?? '').trim()
const isQueued = t.status === 'queued'
const canCancel = (isRunning || isQueued) && t.cancellable && onCancel
return ( return (
<div <div
key={t.id} key={t.id}
className={ className={
"rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5 " + 'rounded-xl border border-gray-200 bg-white px-2 py-2 dark:border-white/10 dark:bg-white/5 ' +
"transition-opacity duration-500 " + 'transition-opacity duration-500 ' +
(t.fading ? "opacity-0" : "opacity-100") (t.fading ? 'opacity-0' : 'opacity-100')
} }
> >
<div className="flex items-center gap-3"> <div className="flex items-stretch gap-1 sm:min-h-[24px] sm:items-center">
{/* Left icon: Cancel X (running+cancellable) OR green check (done) */} {/* Left action / icon column */}
<div className="shrink-0"> <div className="shrink-0 self-stretch flex">
{isRunning && t.cancellable && onCancel ? ( {canCancel ? (
<button <button
type="button" type="button"
onClick={() => onCancel?.(t.id)} onClick={() => onCancel?.(t.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md className="
text-red-700 hover:bg-red-50 hover:text-red-900 inline-flex w-9 items-center justify-center self-stretch rounded-lg
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200" text-red-700 hover:bg-red-50 hover:text-red-900
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200
"
title="Abbrechen" title="Abbrechen"
aria-label="Task abbrechen" aria-label="Task abbrechen"
> >
@ -87,67 +91,80 @@ export default function TaskList({ tasks, onCancel }: Props) {
</button> </button>
) : t.status === 'done' ? ( ) : t.status === 'done' ? (
<span <span
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-green-700 dark:text-green-300" className="inline-flex w-9 items-center justify-center self-stretch rounded-lg text-green-700 dark:text-green-300"
title="Fertig" title="Fertig"
aria-label="Fertig" aria-label="Fertig"
> >
</span> </span>
) : ( ) : (
<span className="inline-block h-7 w-7" /> <span className="inline-flex w-9 self-stretch" />
)} )}
</div> </div>
{/* One-line row: title (optional) + status + progress */} {/* Right content */}
<div className="min-w-0 flex-1 flex items-center gap-2"> <div className="min-w-0 flex-1 flex flex-col justify-center">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white"> <div className="flex items-start gap-2 sm:items-center">
{title || 'Aufgabe'} <div className="min-w-0 flex-1">
{suffix ? ( {/* Mobile: Title oben */}
<span className="font-normal text-gray-600 dark:text-gray-300"> <div className="sm:hidden truncate text-sm font-semibold text-gray-900 dark:text-white">
{' · '}{suffix} {title || 'Aufgabe'}
</div>
{/* Desktop: Title + suffix in einer Zeile */}
<div className="hidden sm:block min-w-0 truncate text-sm text-gray-900 dark:text-white">
<span className="font-semibold">{title || 'Aufgabe'}</span>
{suffix ? (
<span className="ml-1 font-normal text-gray-600 dark:text-gray-300">
· {suffix}
</span>
) : null}
</div>
</div>
{t.status === 'done' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
Fertig
</span> </span>
) : t.status === 'cancelled' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
Abgebrochen
</span>
) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
Fehler
</span>
) : null}
{hasProgress ? (
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
<span className="tabular-nums">{p}%</span>
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
</div>
</div>
) : null} ) : null}
</div> </div>
{/* Status badge (running bewusst ohne Badge) */} {/* Mobile: suffix unter dem Title */}
{t.status === 'done' ? ( {suffix ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30"> <div
Fertig className="mt-0.5 min-w-0 truncate text-xs text-gray-600 dark:text-gray-300 sm:hidden"
</span> title={suffix}
) : t.status === 'cancelled' ? ( >
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10"> {suffix}
Abgebrochen </div>
</span>
) : t.status === 'error' ? (
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
Fehler
</span>
) : null} ) : null}
{/* Spacer */} {t.status === 'error' && t.err ? (
<div className="flex-1" /> <div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t.err}
{/* Progress compact on same line */}
{hasProgress ? (
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
<span className="tabular-nums">{p}%</span>
{/* Progressbar: auf Mobile ausblenden, ab sm anzeigen */}
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
</div>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
{/* Error text optional (unter der Zeile) */}
{t.status === 'error' && t.err ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t.err}
</div>
) : null}
</div> </div>
) )
})} })}

View File

@ -228,6 +228,9 @@ export type StoredModelFlags = {
liked?: boolean | null liked?: boolean | null
watching?: boolean | null watching?: boolean | null
tags?: string tags?: string
gender?: string | null
sex?: string | null
subgender?: string | null
} }
export type GalleryModelFlags = StoredModelFlags & { export type GalleryModelFlags = StoredModelFlags & {