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
}
func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return false
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
fileName := filepath.Base(outPath)
assetID := assetIDFromVideoPath(outPath)
if assetID == "" {
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{
Key: key,
Key: "enrich:" + assetID,
Added: time.Now(),
Run: func(ctx context.Context) error {
if job != nil {
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
return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
},
})
if ok && job != nil {
st := enrichQ.StatusForKey(key)
publishFinishedPostworkStateForJob(
job,
"enrich",
st.State,
"assets",
"Wartet",
st.Position,
st.Waiting,
st.Running,
st.MaxParallel,
)
if ok {
return true
}
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.

View File

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

View File

@ -145,6 +145,11 @@ type modelsOverviewModel struct {
Input string `json:"input"`
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"`
Gender string `json:"gender,omitempty"`
Sex string `json:"sex,omitempty"`
Subgender string `json:"subgender,omitempty"`
Watching bool `json:"watching"`
Favorite bool `json:"favorite,omitempty"`
Liked *bool `json:"liked,omitempty"`

View File

@ -23,6 +23,11 @@ type StoredModel struct {
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
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"`
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
@ -1048,6 +1053,15 @@ ORDER BY updated_at DESC;
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{
ID: id,
Input: input,
@ -1055,6 +1069,11 @@ ORDER BY updated_at DESC;
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
Tags: tags,
LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
@ -1870,6 +1889,15 @@ 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{
ID: id,
Input: input,
@ -1877,6 +1905,10 @@ WHERE id=$1;
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Sex: sex,
Subgender: subgender,
Tags: tags,
LastStream: fmtNullTime(lastStream),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import (
"os/exec"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
@ -1532,36 +1533,117 @@ func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *
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) {
jobsMu.RLock()
job := jobs[id]
jobsMu.RUnlock()
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)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
if job.Status == JobRunning {
serveLivePreviewJPGFile(w, r, jpgPath)
} else {
servePreviewJPGFile(w, r, jpgPath)
}
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)
return
}
@ -1634,6 +1716,14 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
low := strings.ToLower(strings.TrimSpace(file))
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)
return
}
@ -1649,73 +1739,11 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if ok {
if job.Status == JobRunning {
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 {
serveLivePreviewJPGFile(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)
if serveRunningJobPreview(w, r, job) {
return
}
jobsMu.RLock()
state := strings.TrimSpace(job.PreviewState)
jobsMu.RUnlock()
state := strings.TrimSpace(previewStateForJob(job))
if state == "private" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return
@ -1729,10 +1757,228 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
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
}
}
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
// Finished file preview
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) {
jobsMu.RLock()
status := job.Status
@ -1746,21 +1992,16 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
return
}
assetID := assetIDForJob(job)
thumbPath, err := generatedThumbJPGFile(assetID)
if err != nil {
return
}
job.previewMu.Lock()
lastAt := job.previewJPGAt
job.previewMu.Unlock()
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 {
if !lastAt.IsZero() && time.Since(lastAt) < 18*time.Second {
return
}
}
if thumbSem != nil {
thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
thumbCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if err := thumbSem.Acquire(thumbCtx); err != nil {
return
@ -1770,7 +2011,24 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
var img []byte
if previewM3U8 != "" {
provider := detectPreviewProvider(job, out)
previewState := previewStateForJob(job)
imageURL := strings.TrimSpace(previewImageURLForJob(job))
if provider == "chaturbate" {
if imageURL != "" {
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
}
@ -1781,12 +2039,16 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
img = b
}
}
}
if len(img) == 0 {
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) {
@ -1804,7 +2066,7 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
case <-time.After(20 * time.Second):
jobsMu.RLock()
st := job.Status
jobsMu.RUnlock()

View File

@ -91,11 +91,6 @@ func RecordStream(
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
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

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) {
finalStatus := JobFailed
if postTarget == JobStopped {
@ -1223,15 +1384,6 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
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 {

View File

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

View File

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

View File

@ -90,6 +90,13 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
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…")
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
}
job.State = "running"
job.StartedAt = time.Now()
return job
}
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) {
regenerateAssetsMu.Lock()
defer regenerateAssetsMu.Unlock()
@ -305,7 +320,24 @@ func regenerateAssetsWorker() {
continue
}
// ✅ 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,
RegenerateAssetsJobs: regenerateJobs,
Cleanup: cleanup,
AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running,
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running,
}
w.Header().Set("Content-Type", "application/json")

View File

@ -16,7 +16,7 @@ import ModelDetails from './components/ui/ModelDetails'
import {
SignalIcon,
HeartIcon,
HandThumbUpIcon,
StarIcon,
EyeIcon,
ArrowDownTrayIcon,
ArchiveBoxIcon,
@ -149,6 +149,7 @@ type StoredModel = {
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
@ -159,6 +160,11 @@ type StoredModel = {
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
lastOnlineAt?: string
lastOfflineAt?: string
lastRoomSyncAt?: string
@ -2318,12 +2324,16 @@ export default function App() {
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot])
// ✅ 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)
if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0)
const silent = Boolean(opts?.silent)
const immediate = Boolean(opts?.immediate)
if (!silent) setError(null)
@ -2360,6 +2370,9 @@ export default function App() {
const keyLower = providerKeyLowerFromUrl(norm)
// Nur automatische Starts in pending-autostart schieben.
// Manuelle UI-Starts müssen sofort direkt starten.
if (!immediate) {
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
@ -2375,6 +2388,7 @@ export default function App() {
})
return true
}
}
try {
const cookieString = Object.entries(currentCookies)
@ -2926,7 +2940,7 @@ export default function App() {
}, [playerJob, modelsByKey])
async function onStart() {
const ok = await startUrl(sourceUrl, { silent: false })
const ok = await startUrl(sourceUrl, { silent: false, immediate: true })
if (ok) {
setSourceUrl('')
@ -2958,7 +2972,7 @@ export default function App() {
}
const url = canonicalizeProviderUrl(norm0)
const ok = await startUrl(url, { silent: true })
const ok = await startUrl(url, { silent: true, immediate: true })
if (!ok) {
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
@ -3618,12 +3632,12 @@ export default function App() {
</span>
<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>
<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>
</div>
@ -3661,12 +3675,12 @@ export default function App() {
</span>
<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>
<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>
</div>

View File

@ -26,7 +26,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
}
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 = {
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 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft:
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
'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:inset-ring-0 dark:hover:bg-indigo-500/30',
},
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 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft:
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30',
'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:inset-ring-0 dark:hover:bg-blue-500/30',
},
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 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft:
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30',
'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:inset-ring-0 dark:hover:bg-emerald-500/30',
},
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 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft:
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30',
'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:inset-ring-0 dark:hover:bg-red-500/30',
},
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 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
secondary:
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
'bg-white text-gray-900 hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
soft:
'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30',
'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:inset-ring-0 dark:hover:bg-amber-500/30',
},
}

View File

@ -7,9 +7,9 @@ type Size = 'sm' | 'md' | 'lg'
export type ButtonGroupItem = {
id: string
label?: React.ReactNode // optional (für icon-only)
label?: React.ReactNode
icon?: React.ReactNode
srLabel?: string // für icon-only (Screenreader)
srLabel?: string
disabled?: boolean
}
@ -45,7 +45,7 @@ export default function ButtonGroup({
return (
<span
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
)}
role="group"
@ -65,20 +65,28 @@ export default function ButtonGroup({
onClick={() => onChange(it.id)}
aria-pressed={active}
className={cn(
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
!isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
'relative inline-flex items-center justify-center font-semibold leading-none',
'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',
isLast && 'rounded-r-md',
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
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:opacity-50 disabled:cursor-not-allowed',
// Padding / Größe
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
)}
title={typeof it.label === 'string' ? it.label : it.srLabel}
@ -88,9 +96,11 @@ export default function ButtonGroup({
{it.icon ? (
<span
className={cn(
'shrink-0',
'shrink-0 transition-colors duration-150',
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}

View File

@ -7,11 +7,13 @@ import Table, { type Column } from './Table'
import Card from './Card'
import Button from './Button'
import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { useMediaQuery } from '../../lib/useMediaQuery'
import DownloadsCardRow, {
PendingPreviewImage,
type DownloadRow,
type PendingWatchedRoom,
} from './DownloadsCardRow'
@ -38,6 +40,9 @@ type Props = {
watching?: boolean
roomStatus?: string
imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
}
>
roomStatusByModelKey?: Record<string, string>
@ -56,6 +61,31 @@ type Props = {
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 anyP = p as any
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 anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
@ -588,8 +611,6 @@ export default function Downloads({
const [watchedBusy, setWatchedBusy] = useState(false)
const [thumbTick, setThumbTick] = useState(0)
const refreshWatchedState = useCallback(async () => {
try {
await onRefreshAutostartState?.()
@ -598,33 +619,6 @@ export default function Downloads({
}
}, [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(() => {
const nextPaused = Boolean(autostartState?.paused)
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
@ -971,8 +965,7 @@ export default function Downloads({
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey)
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
@ -982,8 +975,7 @@ export default function Downloads({
>
<ModelPreview
jobId={j.id}
initialSrc={onlineImageUrl || providerFallbackSrc}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
initialSrc={directPreviewSrc || undefined}
enableHoverLive={true}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
@ -992,7 +984,7 @@ export default function Downloads({
modelsByKey,
growingByJobId
)}
fallbackSrc={providerFallbackSrc}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
className="w-full h-full"
/>
</LazyMountWhenVisible>
@ -1007,18 +999,11 @@ export default function Downloads({
if (img) {
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">
<img
<PendingPreviewImage
src={img}
alt={name}
className={[
'h-full w-full object-cover',
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
blur={blurPreviews}
className="h-full w-full"
/>
</div>
)
@ -1040,6 +1025,8 @@ export default function Downloads({
const j = r.job
const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output)
const modelKey = modelKeyFromJob(j)
const modelMeta = modelKey ? modelsByKey[modelKey] : undefined
const roomStatus = effectiveRoomStatusOfJob(
j,
@ -1054,6 +1041,14 @@ export default function Downloads({
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<ModelGenderIcon
gender={modelMeta?.gender}
sex={modelMeta?.sex}
subgender={modelMeta?.subgender}
className="shrink-0"
/>
<span
className={[
'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 url = pendingUrl(p)
const pendingKey = String((p as any)?.modelKey ?? name).trim().toLowerCase()
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
return (
<>
<div className="flex min-w-0 items-center gap-2">
<span className="block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<ModelGenderIcon
gender={pendingFlags?.gender}
sex={pendingFlags?.sex}
subgender={pendingFlags?.subgender}
className="shrink-0"
/>
</div>
{url ? (
<a
href={url}
@ -1177,11 +1185,48 @@ export default function Downloads({
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
if (r.kind !== 'job') {
const pendingKey = pendingRowKey(r.pending)
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
const pendingKey = String(
(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 (
<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
size="sm"
variant="primary"
@ -1192,11 +1237,11 @@ export default function Downloads({
e.stopPropagation()
if (isRemovingPending) return
markRemovePendingRequested(pendingKey)
markRemovePendingRequested(removeKey)
try {
await onRemovePending?.(r.pending)
} catch {
clearRemovePendingRequested(pendingKey)
clearRemovePendingRequested(removeKey)
}
}}
>
@ -1634,7 +1679,6 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
@ -1692,7 +1736,6 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
@ -1774,7 +1817,6 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}

View File

@ -2,8 +2,10 @@
'use client'
import { useMemo, useState, useRef, useEffect } from 'react'
import Button from './Button'
import ModelPreview from './ModelPreview'
import ModelGenderIcon from './ModelGenderIcon'
import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
@ -31,12 +33,14 @@ type ModelFlags = {
watching?: boolean
roomStatus?: string
imageUrl?: string
gender?: string | null
sex?: string | null
subgender?: string | null
}
type Props = {
r: DownloadRow
nowMs: number
thumbTick?: number
blurPreviews?: boolean
modelsByKey: Record<string, ModelFlags>
roomStatusByModelKey: Record<string, string>
@ -126,6 +130,31 @@ const modelKeyFromJob = (job: RecordJob): string => {
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 = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
@ -441,10 +470,96 @@ function postWorkLabel(
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({
r,
nowMs,
thumbTick,
blurPreviews,
modelsByKey,
roomStatusByModelKey,
@ -464,7 +579,25 @@ export default function DownloadsCardRow({
const name = pendingModelName(p)
const url = pendingUrl(p)
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 (
<div
@ -483,26 +616,18 @@ export default function DownloadsCardRow({
<div className="flex items-start gap-3">
<div
className="
relative
shrink-0 overflow-hidden rounded-lg
relative shrink-0 overflow-hidden rounded-lg
w-[112px] h-[64px]
bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10
"
>
{img ? (
<img
<PendingPreviewImage
src={img}
alt={name}
className={[
'h-full w-full object-cover',
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
blur={blurPreviews}
className="h-full w-full"
/>
) : (
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
@ -512,19 +637,33 @@ export default function DownloadsCardRow({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<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}
>
{name}
</div>
<ModelGenderIcon
gender={pendingFlags?.gender}
sex={pendingFlags?.sex}
subgender={pendingFlags?.subgender}
className="shrink-0"
/>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Warte auf Online-Status
</div>
</div>
<div className="shrink-0 flex items-start">
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(show),
].join(' ')}
title={show}
@ -532,17 +671,6 @@ export default function DownloadsCardRow({
{show}
</span>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
Wartend auf Autostart
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-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">
</span>
</div>
</div>
{url ? (
@ -560,21 +688,35 @@ export default function DownloadsCardRow({
</div>
</div>
<div className="mt-3">
<ProgressBar
label="Warte auf Online-Status…"
indeterminate
size="sm"
className="w-full"
<div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
<div
className="flex items-center gap-1"
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"
/>
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
<div className="flex items-center gap-1 opacity-60">
<span className="text-xs text-gray-500 dark:text-gray-400">
Wird automatisch gestartet, sobald das Model public ist.
<div
className="ml-auto flex items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<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">
Wartend
</span>
</div>
<div className="hidden sm:block">
<Button
@ -590,6 +732,7 @@ export default function DownloadsCardRow({
</div>
</div>
</div>
</div>
)
}
@ -654,18 +797,12 @@ export default function DownloadsCardRow({
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
const key = name && name !== '—' ? name.toLowerCase() : ''
const flags = key ? modelsByKey[key] : undefined
const modelKey = modelKeyFromJob(j)
const flags = modelKey ? modelsByKey[modelKey] : undefined
const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
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 directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
return (
<div
@ -703,11 +840,7 @@ export default function DownloadsCardRow({
>
<ModelPreview
jobId={j.id}
initialSrc={
onlineImageUrlOfJob(j, modelsByKey) ||
`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
initialSrc={directPreviewSrc || undefined}
enableHoverLive={false}
blur={blurPreviews}
roomStatus={previewRoomStatusOfJob(
@ -723,15 +856,36 @@ export default function DownloadsCardRow({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
title={name}
>
{name}
</div>
<ModelGenderIcon
gender={flags?.gender}
sex={flags?.sex}
subgender={flags?.subgender}
className="shrink-0"
/>
</div>
<div
className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300"
title={j.output}
>
{file || '—'}
</div>
</div>
<div className="shrink-0 flex items-start">
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(roomStatus),
].join(' ')}
title={roomStatus}
@ -739,19 +893,6 @@ export default function DownloadsCardRow({
{roomStatus}
</span>
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
{file || '—'}
</div>
</div>
<div className="shrink-0 flex flex-col items-end gap-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">
{runtimeOf(j, nowMs)}
</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">
{formatBytes(sizeBytesOf(j))}
</span>
</div>
</div>
{j.sourceUrl ? (
@ -790,7 +931,7 @@ export default function DownloadsCardRow({
</div>
) : 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
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
@ -811,6 +952,20 @@ export default function DownloadsCardRow({
/>
</div>
<div
className="ml-auto flex min-w-0 self-center items-center justify-end gap-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-end gap-2 text-right">
<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">
{runtimeOf(j, nowMs)}
</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>
<div className="hidden sm:block">
<Button
size="sm"
@ -837,5 +992,6 @@ export default function DownloadsCardRow({
</div>
</div>
</div>
</div>
)
}

View File

@ -185,9 +185,11 @@ function enrichJobForDetails(job: RecordJob, modelsByKey: Record<string, StoredM
const href = modelHrefFromFlags(model, flags)
if (job.sourceUrl || !href) return job
return {
...job,
sourceUrl: job.sourceUrl || href,
sourceUrl: href,
}
}
@ -915,13 +917,6 @@ function buildPostworkSummaryFromMetaHistory(
return summarizePostworkSteps(file, badges)
}
function isAggregateAssetsPhase(
queue: 'postwork' | 'enrich',
phase?: string
): boolean {
return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets'
}
function normalizeFinishedPhase(
queue: 'postwork' | 'enrich',
phase?: string
@ -929,8 +924,14 @@ function normalizeFinishedPhase(
const p = String(phase ?? '').trim().toLowerCase()
if (queue === 'enrich') {
if (p === 'analyze') return 'analyze'
return p || 'analyze'
switch (p) {
case '':
case 'assets':
case 'analyze':
return 'analyze'
default:
return p
}
}
switch (p) {
@ -962,21 +963,9 @@ function summarizePostworkSteps(
const expectedSteps = expectedStepsForBadges()
const byKey = new Map<string, FinishedPostworkStepSummary>()
let aggregateAssetsBadge: FinishedPostworkBadge | null = null
for (const badge of realBadges) {
const queue = badge.queue
const rawPhase = String(badge.phase ?? '').trim().toLowerCase()
// ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen
// sondern später auf die bekannten Schritte verteilen.
if (isAggregateAssetsPhase(queue, rawPhase)) {
if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) {
aggregateAssetsBadge = badge
}
continue
}
const phase = normalizeFinishedPhase(queue, rawPhase)
const key = `${queue}:${phase}`
@ -1012,27 +1001,6 @@ function summarizePostworkSteps(
})
}
// ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden
if (aggregateAssetsBadge) {
for (const stepDef of expectedSteps) {
const cur = byKey.get(stepDef.key)
if (!cur) continue
// Bereits fertige Schritte beibehalten
if (cur.state === 'done') continue
byKey.set(stepDef.key, {
...cur,
state: aggregateAssetsBadge.state,
ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0),
position: aggregateAssetsBadge.position,
waiting: aggregateAssetsBadge.waiting,
running: aggregateAssetsBadge.running,
maxParallel: aggregateAssetsBadge.maxParallel,
})
}
}
const order = expectedSteps.map((s) => s.key)
const steps = Array.from(byKey.values()).sort((a, b) => {
@ -1207,6 +1175,39 @@ function mergePostworkSummaries(
}
}
function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): boolean {
return (
a.file === b.file &&
a.assetId === b.assetId &&
a.queue === b.queue &&
a.phase === b.phase &&
a.state === b.state &&
a.label === b.label &&
a.error === b.error &&
a.position === b.position &&
a.waiting === b.waiting &&
a.running === b.running &&
a.maxParallel === b.maxParallel &&
(a.ts ?? 0) === (b.ts ?? 0)
)
}
function samePostworkBadgeList(
a?: FinishedPostworkBadge[],
b?: FinishedPostworkBadge[]
): boolean {
const aa = Array.isArray(a) ? a : []
const bb = Array.isArray(b) ? b : []
if (aa.length !== bb.length) return false
for (let i = 0; i < aa.length; i++) {
if (!samePostworkBadge(aa[i], bb[i])) return false
}
return true
}
export function renderFinishedPostworkBadge(
summary?: FinishedPostworkSummary,
opts?: {
@ -1505,6 +1506,8 @@ export default function FinishedDownloads({
const removeTimersRef = React.useRef<Map<string, number>>(new Map())
const regenerateTaskFilesRef = React.useRef<Set<string>>(new Set())
// -----------------------------------------------------------------------------
// memoized derived data
// -----------------------------------------------------------------------------
@ -1777,6 +1780,9 @@ export default function FinishedDownloads({
pageRows.length > 0 &&
pageRows.every((j) => selectedKeys.has(keyFor(j)))
const hasSearchQuery = (searchQuery || '').trim() !== ''
const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen
// -----------------------------------------------------------------------------
// callbacks
// -----------------------------------------------------------------------------
@ -2689,53 +2695,22 @@ export default function FinishedDownloads({
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
const assetId = fileStemFromOutput(cleanFile)
const now = Date.now()
// Nur den ersten Schritt optimistisch als queued setzen.
// Die weiteren Phasen sollen erst durch echte SSE-/Backend-Events auftauchen.
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
assetId,
queue: 'postwork',
state: 'queued',
phase: 'meta',
label: 'Meta',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'postwork',
state: 'queued',
phase: 'sprites',
label: 'Sprites',
ts: Date.now(),
},
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
state: 'queued',
phase: 'analyze',
label: 'KI-Analyse',
ts: Date.now(),
ts: now,
},
],
}))
@ -2759,16 +2734,19 @@ export default function FinishedDownloads({
error: e?.message ? String(e.message) : String(e),
})
// Request/Enqueue ist fehlgeschlagen -> KEIN Sammelstatus, KEIN enrich:assets.
// Wir markieren nur den ersten Schritt als Fehler.
setPostworkByFile((prev) => ({
...prev,
[cleanFile]: [
{
file: cleanFile,
assetId: fileStemFromOutput(cleanFile),
queue: 'enrich',
assetId,
queue: 'postwork',
state: 'error',
phase: 'assets',
phase: 'meta',
label: 'Fehler',
error: e?.message ? String(e.message) : String(e),
ts: Date.now(),
},
],
@ -2800,10 +2778,19 @@ export default function FinishedDownloads({
return '—'
}, [durations])
const registerTeaserHost = useCallback(
(key: string) => (el: HTMLDivElement | null) => {
const teaserHostCallbackMapRef = React.useRef(
new Map<string, (el: HTMLDivElement | null) => void>()
)
const registerTeaserHost = useCallback((key: string) => {
const existing = teaserHostCallbackMapRef.current.get(key)
if (existing) return existing
const cb = (el: HTMLDivElement | null) => {
const prev = teaserHostsRef.current.get(key)
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
if (prev && teaserIORef.current && prev !== el) {
teaserIORef.current.unobserve(prev)
}
if (el) {
teaserHostsRef.current.set(key, el)
@ -2813,9 +2800,11 @@ export default function FinishedDownloads({
if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev)
teaserHostsRef.current.delete(key)
}
},
[]
)
}
teaserHostCallbackMapRef.current.set(key, cb)
return cb
}, [])
const handleDuration = useCallback((job: RecordJob, seconds: number) => {
if (!Number.isFinite(seconds) || seconds <= 0) return
@ -3151,36 +3140,44 @@ export default function FinishedDownloads({
setInlinePlay(null)
}, [pauseTeasers])
useEffect(() => {
let alive = true
const loadInitialPostworkState = async () => {
const refreshPostworkState = useCallback(async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
if (!data) return
const jobs = Array.isArray(data?.regenerateAssetsJobs)
? data.regenerateAssetsJobs
: []
if (jobs.length > 0) {
const syncedFiles = new Set<string>()
setPostworkByFile((prev) => {
let changed = false
const next = { ...prev }
for (const oldFile of regenerateTaskFilesRef.current) {
if (!syncedFiles.has(oldFile) && oldFile in next) {
delete next[oldFile]
changed = true
}
}
for (const item of jobs) {
const file = String(item?.file ?? '').trim()
if (!file) continue
let nextBadges: FinishedPostworkBadge[] | undefined
const assetId =
String(item?.assetId ?? '').trim() || fileStemFromOutput(file)
const phases = Array.isArray(item?.phases) ? item.phases : []
if (phases.length > 0) {
const restored = phases
nextBadges = phases
.map((p: any): FinishedPostworkBadge | null => {
const queueRaw = String(p?.queue ?? '').trim().toLowerCase()
const phaseRaw = String(p?.phase ?? '').trim().toLowerCase()
@ -3213,30 +3210,15 @@ export default function FinishedDownloads({
label:
String(p?.label ?? '').trim() ||
labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw),
position:
typeof p?.position === 'number' ? p.position : undefined,
waiting:
typeof p?.waiting === 'number' ? p.waiting : undefined,
running:
typeof p?.running === 'number' ? p.running : undefined,
maxParallel:
typeof p?.maxParallel === 'number'
? p.maxParallel
: undefined,
ts:
typeof p?.ts === 'number' && Number.isFinite(p.ts)
? p.ts
: Date.now(),
position: typeof p?.position === 'number' ? p.position : undefined,
waiting: typeof p?.waiting === 'number' ? p.waiting : undefined,
running: typeof p?.running === 'number' ? p.running : undefined,
maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined,
ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : 0,
}
})
.filter(Boolean) as FinishedPostworkBadge[]
if (restored.length > 0) {
next[file] = restored
continue
}
}
} else {
const rawState = String(item?.state ?? '').trim().toLowerCase()
const state: FinishedPostworkState =
@ -3250,11 +3232,8 @@ export default function FinishedDownloads({
? 'missing'
: 'missing'
if (state !== 'queued' && state !== 'running' && state !== 'error') {
continue
}
next[file] = [
if (state === 'queued' || state === 'running' || state === 'error') {
nextBadges = [
{
file,
assetId,
@ -3262,60 +3241,64 @@ export default function FinishedDownloads({
state,
phase: 'meta',
label: 'Meta',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'thumb',
label: 'Vorschaubild',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'teaser',
label: 'Teaser',
ts: Date.now(),
},
{
file,
assetId,
queue: 'postwork',
state,
phase: 'sprites',
label: 'Sprites',
ts: Date.now(),
},
{
file,
assetId,
queue: 'enrich',
state,
phase: 'analyze',
label: 'KI-Analyse',
ts: Date.now(),
ts: 0,
},
]
}
}
return next
const prevBadges = prev[file]
if (nextBadges) {
if (!samePostworkBadgeList(prevBadges, nextBadges)) {
next[file] = nextBadges
changed = true
}
} else if (file in next) {
delete next[file]
changed = true
}
}
return changed ? next : prev
})
}
} catch {}
}
void loadInitialPostworkState()
return () => {
alive = false
regenerateTaskFilesRef.current = syncedFiles
} catch {
// ignore
}
}, [])
useEffect(() => {
void refreshPostworkState()
}, [refreshPostworkState])
useEffect(() => {
const onFocus = () => {
void refreshPostworkState()
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void refreshPostworkState()
}
}
const onPageShow = () => {
void refreshPostworkState()
}
window.addEventListener('focus', onFocus)
window.addEventListener('pageshow', onPageShow)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
window.removeEventListener('focus', onFocus)
window.removeEventListener('pageshow', onPageShow)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [refreshPostworkState])
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY }
@ -3580,6 +3563,39 @@ export default function FinishedDownloads({
}
}, [selectedCount, selectionActionsOpen])
const desktopToolbarControlsClass = [
'flex items-center gap-3',
'flex-nowrap',
'md:flex-wrap',
'xl:flex-nowrap',
].join(' ')
const desktopToolbarLeftClass = [
'flex min-w-0 shrink-0 items-center gap-2',
'flex-nowrap',
].join(' ')
const desktopToolbarRightClass = [
'flex min-w-0 items-center gap-2',
'flex-nowrap',
'md:ml-0 md:basis-full md:flex-wrap',
'xl:ml-auto xl:basis-auto xl:flex-nowrap xl:justify-end',
].join(' ')
const renderClickablePostworkBadge = useCallback(
(badge?: FinishedPostworkSummary) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
}),
[isSmall, regenerateAssetsForFile]
)
// -----------------------------------------------------------------------------
// render
// -----------------------------------------------------------------------------
@ -3596,7 +3612,8 @@ export default function FinishedDownloads({
>
{/* Desktop */}
<div className="hidden sm:block px-3 py-2.5">
<div className="flex items-center gap-2 pb-2">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Abgeschlossene Downloads
@ -3606,50 +3623,46 @@ export default function FinishedDownloads({
</span>
</div>
<div className="ml-auto flex w-full max-w-[420px] items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
<LoadingSpinner
size="lg"
className={[
'text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none',
].join(' ')}
className="text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none"
srLabel="Lade Downloads…"
/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="min-w-0 flex flex-1 items-center gap-2">
<div className="relative w-full">
<TextInput
value={searchQuery}
size='sm'
size="sm"
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suchen…"
className={hasSearchQuery ? 'w-full pr-9' : 'w-full'}
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Leeren
</Button>
) : null}
</div>
{selectedCount > 0 ? (
<Button
size="md"
variant="soft"
disabled={bulkBusy}
onClick={() => setSelectionActionsOpen((v) => !v)}
title={selectionActionsOpen ? 'Auswahl-Aktionen ausblenden' : 'Auswahl-Aktionen anzeigen'}
<button
type="button"
onClick={clearSearch}
aria-label="Suche leeren"
title="Suche leeren"
className={[
'shrink-0 font-semibold',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
'absolute right-2 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md transition-colors',
hasSearchQuery
? 'text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200'
: 'pointer-events-none opacity-0',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
) : null}
<XMarkIcon className="size-4" />
</button>
</div>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<div className="flex h-9 items-center gap-2 rounded-lg border border-gray-200/70 bg-white/60 px-3 dark:border-white/10 dark:bg-white/5">
<div className={desktopToolbarControlsClass}>
{/* links */}
<div className={desktopToolbarLeftClass}>
<div className="flex h-9 shrink-0 items-center gap-2 rounded-lg border border-gray-200/70 bg-white/60 px-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
label="Behaltene Downloads anzeigen"
checked={includeKeep}
@ -3660,10 +3673,33 @@ export default function FinishedDownloads({
/>
</div>
{view !== 'table' ? (
<div className="flex items-center gap-3">
{selectedCount > 0 ? (
<div className="shrink-0">
<Button
size="sm"
variant="soft"
disabled={bulkBusy}
onClick={() => setSelectionActionsOpen((v) => !v)}
title={
selectionActionsOpen
? 'Auswahl-Aktionen ausblenden'
: 'Auswahl-Aktionen anzeigen'
}
className={[
'justify-center whitespace-nowrap font-semibold transition-opacity',
selectionActionsOpen ? 'ring-2 ring-indigo-500/60' : '',
].join(' ')}
>
{selectedCount} ausgewählt
</Button>
</div>
) : null}
</div>
{/* rechts */}
<div className={desktopToolbarRightClass}>
{view === 'gallery' ? (
<div className="w-[180px]">
<div className="w-[160px] shrink-0 md:w-[180px]">
<Slider
id="finished-gallery-card-scale"
label="Größe"
@ -3678,7 +3714,8 @@ export default function FinishedDownloads({
</div>
) : null}
<div>
{view !== 'table' ? (
<div className="w-[190px] shrink-0 md:w-[220px]">
<label className="sr-only" htmlFor="finished-sort">
Sortierung
</label>
@ -3691,7 +3728,7 @@ export default function FinishedDownloads({
if (page !== 1) onPageChange(1)
}}
className="
h-9 rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-900 shadow-sm
h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
>
@ -3705,12 +3742,11 @@ export default function FinishedDownloads({
<option value="size_asc">Größe </option>
</select>
</div>
</div>
) : null}
<Button
size="md"
variant={lastAction ? "soft" : "secondary"}
variant={lastAction ? 'soft' : 'secondary'}
color="emerald"
disabled={!lastAction || undoing}
onClick={undoLastAction}
@ -3720,10 +3756,12 @@ export default function FinishedDownloads({
? 'Keine Aktion zum Rückgängig machen'
: `Letzte Aktion rückgängig machen (${lastAction.kind})`
}
className="shrink-0"
>
Undo
</Button>
<div className="shrink-0">
<ButtonGroup
value={view}
onChange={(id) => setView(id as ViewMode)}
@ -3753,6 +3791,8 @@ export default function FinishedDownloads({
</div>
</div>
</div>
</div>
</div>
{/* Mobile */}
<div className="sm:hidden">
@ -3859,18 +3899,21 @@ export default function FinishedDownloads({
</div>
</div>
{selectedCount > 0 ? (
<div
className={[
'overflow-hidden border-t border-gray-200/60 dark:border-white/10 transition-all duration-200 ease-in-out',
selectionActionsOpen ? 'max-h-72 opacity-100' : 'max-h-0 opacity-0',
'overflow-hidden transition-[max-height,opacity,border-color] duration-200 ease-in-out',
selectionActionsVisible
? 'max-h-72 opacity-100 border-t border-gray-200/60 dark:border-white/10'
: 'max-h-0 opacity-0 border-t border-transparent dark:border-transparent',
].join(' ')}
aria-hidden={!selectionActionsOpen}
aria-hidden={!selectionActionsVisible}
>
<div
className={[
'px-3 py-3 transition-transform duration-200 ease-in-out',
selectionActionsOpen ? 'translate-y-0' : '-translate-y-1 pointer-events-none',
selectionActionsVisible
? 'translate-y-0'
: '-translate-y-1 pointer-events-none',
].join(' ')}
>
<div className="sm:hidden">
@ -4000,7 +4043,6 @@ export default function FinishedDownloads({
</div>
</div>
</div>
) : null}
{tagFilter.length > 0 ? (
<div className="hidden sm:flex items-center gap-2 border-t border-gray-200/60 dark:border-white/10 px-3 py-2">
@ -4261,17 +4303,7 @@ export default function FinishedDownloads({
forcePreviewMuted={forcePreviewMuted}
mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked}
onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
renderPostworkBadge={renderClickablePostworkBadge}
/>
</div>
)}
@ -4326,17 +4358,7 @@ export default function FinishedDownloads({
postworkByFile={effectivePostworkSummaryByFile}
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
renderPostworkBadge={renderClickablePostworkBadge}
/>
)}
@ -4386,17 +4408,7 @@ export default function FinishedDownloads({
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
forcePreviewMuted={forcePreviewMuted}
cardScale={galleryCardScaleLive}
renderPostworkBadge={(badge) =>
renderFinishedPostworkBadge(badge, {
clickable: true,
disablePopover: isSmall,
onClick: () => {
if (!badge?.file) return
if (badge.state === 'running' || badge.state === 'queued') return
void regenerateAssetsForFile(badge.file)
},
})
}
renderPostworkBadge={renderClickablePostworkBadge}
/>
)}
</div>

View File

@ -29,6 +29,7 @@ import {
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
type Props = {
rows: RecordJob[]
@ -536,13 +537,6 @@ export default function FinishedDownloadsCardsView({
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
const previewInstanceKey = [
k,
previewRenderRole,
inlineActive ? 'inline' : 'teaser',
allowTeaserAnimation ? 'anim' : 'still',
].join('::')
const showMobileAudioButton =
Boolean(onUnlockMobileTeaserAudio) &&
isMobileTopCard &&
@ -700,7 +694,6 @@ export default function FinishedDownloadsCardsView({
) : null}
<FinishedVideoPreview
key={previewInstanceKey}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
className="h-full w-full"
@ -859,10 +852,19 @@ export default function FinishedDownloadsCardsView({
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5">
<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>
{renderPostworkBadge && livePostwork ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(livePostwork)}

View File

@ -27,6 +27,7 @@ import {
readPreviewSpriteInfo,
scrubProgressRatioFromIndex,
} from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
function normalizeDurationSeconds(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
@ -48,7 +49,7 @@ type Props = {
) => FinishedPostworkSummary | undefined
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
durations: Record<string, number>
durationSeconds?: number
teaserState: FinishedDownloadsTeaserState
keyFor: (j: RecordJob) => string
@ -100,7 +101,7 @@ function FinishedDownloadsGalleryCardInner({
postworkByFile,
getPostworkSummaryForOutput,
renderPostworkBadge,
durations,
durationSeconds,
teaserState,
keyFor,
baseName,
@ -261,7 +262,7 @@ function FinishedDownloadsGalleryCardInner({
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
const previewDurationSeconds =
durations[k] ??
durationSeconds ??
normalizeDurationSeconds(meta?.media?.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">
<FinishedVideoPreview
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
durationSeconds={previewDurationSeconds}
@ -442,10 +442,19 @@ function FinishedDownloadsGalleryCardInner({
>
<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="min-w-0 flex items-center gap-1.5">
<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>
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}

View File

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

View File

@ -22,6 +22,7 @@ import {
} from './teaserPlayback'
import Checkbox from './Checkbox'
import { parseJobMeta } from './previewSprite'
import ModelGenderIcon from './ModelGenderIcon'
type Props = {
rows: RecordJob[]
@ -365,10 +366,19 @@ export default function FinishedDownloadsTableView({
return (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="min-w-0 flex items-center gap-1.5">
<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>
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}

View File

@ -685,8 +685,8 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => {
if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
}, [previewId, v])
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
}, [previewId])
// --- Inline Video sichtbar?
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) =>
blur ? 'blur-md scale-[1.03] brightness-90' : ''
@ -1400,30 +1428,6 @@ export default function ModelDetails({
/>
</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>
}
width="max-w-6xl"
@ -1483,23 +1487,13 @@ export default function ModelDetails({
{/* Pills */}
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
{showPill ? (
<span
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'
)}
>
<span className={heroStatusPill(showLabel)}>
{showPill}
</span>
) : null}
{effectiveRoom?.is_new ? (
<span
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
)}
>
<span className={heroNewPill()}>
NEW
</span>
) : null}
@ -1870,9 +1864,33 @@ export default function ModelDetails({
{/* 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="min-w-0 flex-1 pr-2">
<div className="flex min-w-0 items-center gap-1.5">
<div className="truncate text-sm font-semibold text-white drop-shadow">
<div className="flex min-w-0 items-center">
<div className="inline-flex min-w-0 max-w-full items-center gap-1.5">
<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>

View File

@ -19,15 +19,14 @@ function normalizeGender(
if (!raw) return null
if (raw.includes('couple') || raw.includes('pair')) {
if (raw.includes('couple') || raw.includes('pair') || raw === 'c') {
return 'couple'
}
if (
raw.includes('trans') ||
raw.includes('transgender') ||
raw.includes('ts') ||
raw.includes('shemale')
raw === 't'
) {
return 'trans'
}
@ -35,7 +34,9 @@ function normalizeGender(
if (
raw.includes('female') ||
raw.includes('girl') ||
raw.includes('woman')
raw.includes('woman') ||
raw === 'f' ||
raw === 'w'
) {
return 'female'
}
@ -43,7 +44,8 @@ function normalizeGender(
if (
raw.includes('male') ||
raw.includes('boy') ||
raw.includes('man')
raw.includes('man') ||
raw === 'm'
) {
return 'male'
}
@ -153,13 +155,22 @@ export default function ModelGenderIcon({
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 (
<span
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
variant === 'hero'
? 'size-5 bg-black/30 text-white ring-white/20 backdrop-blur'
: '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 backdrop-blur shadow-sm', toneClass)
: cn('size-5', toneClass),
className
)}
title={label}

View File

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

View File

@ -14,6 +14,7 @@ import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types'
import TextInput from './TextInput'
import ModelGenderIcon from './ModelGenderIcon'
type ParsedModel = {
input: string
@ -51,6 +52,10 @@ export type StoredModel = {
profileImageCached?: string
profileImageUpdatedAt?: string
gender?: string | null
sex?: string | null
subgender?: string | null
watching: boolean
favorite: boolean
hot: boolean
@ -741,7 +746,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<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 ? (
<span
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
@ -1320,12 +1334,21 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
{/* 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="flex items-center gap-1.5 min-w-0">
<div
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
title={m.modelKey}
>
{m.modelKey}
</div>
<ModelGenderIcon
gender={m.gender}
sex={m.sex}
subgender={m.subgender}
className="shrink-0 text-white"
/>
</div>
{m.host ? (
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{m.host}

View File

@ -57,9 +57,9 @@ function PageButton({
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',
roundedCls,
disabled ? 'opacity-100 cursor-not-allowed' : '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'
disabled
? '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'
: '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}

View File

@ -120,6 +120,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const assetsAbortRef = useRef<AbortController | null>(null)
const assetsCancelRequestedRef = useRef(false)
const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
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 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) => ({
...t,
status: 'running',
@ -278,12 +291,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
assetsError.toLowerCase() === 'cancelled' ||
assetsError.toLowerCase() === 'canceled'
if (cancelled && assetsCancelRequestedRef.current) {
return
}
setAssetsTask((t) => {
if (cancelled && (t.status === 'idle' || t.fading)) {
return t
}
const next: TaskItem = {
return {
...t,
status: cancelled ? 'cancelled' : 'error',
title: 'Assets generieren',
@ -294,10 +311,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
cancellable: false,
fading: t.fading ?? false,
}
return next
})
} else if (assets?.text || assets?.finishedAt) {
} else if (assets?.finishedAt) {
setAssetsTask((t) => ({
...t,
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(() => {
setter((t) => ({ ...t, fading: true }))
window.setTimeout(() => {
@ -672,7 +687,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}, delayMs)
}
function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) {
function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) {
const id = regenerateTaskId(file)
window.setTimeout(() => {
@ -769,34 +784,47 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
async function cancelAssetsTask() {
assetsCancelRequestedRef.current = true
const ac = assetsAbortRef.current
assetsAbortRef.current = null
// UI sofort
// UI ruhig ausblenden statt "Abgebrochen" sichtbar zu zeigen
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
cancellable: false,
fading: true,
}))
window.setTimeout(() => {
setAssetsTask((t: TaskItem) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
err: undefined,
cancellable: false,
done: 0,
total: 0,
cancellable: true,
fading: false,
startedAtMs: 0,
}))
fadeOutTask(setAssetsTask)
}, 500)
if (ac) {
ac.abort()
return
}
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
try {
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
await fetch('/api/tasks/generate-assets', {
method: 'DELETE',
cache: 'no-store' as any,
})
} catch {
// ignore
}
}
}
async function cancelRegenerateAssetsTask(file: string) {
const cleanFile = String(file || '').trim()
@ -920,7 +948,23 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
stopUrl="/api/tasks/generate-assets"
sseUrl="/api/events/stream"
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) => {
assetsCancelRequestedRef.current = false
assetsAbortRef.current = ac
setAssetsTask((t: TaskItem) => ({
...t,
@ -930,6 +974,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
startedAtMs: t.startedAtMs || Date.now(),
}))
@ -955,6 +1000,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}}
onCancelled={() => {
assetsAbortRef.current = null
if (assetsCancelRequestedRef.current) {
assetsCancelRequestedRef.current = false
return
}
setAssetsTask((t: TaskItem) => ({
...t,
status: 'cancelled',
@ -983,7 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
startingLabel="Läuft…"
onTrigger={cleanupSmallDone}
busy={cleaning}
disabled={saving || !value.autoDeleteSmallDownloads}
disabled={saving}
onError={(message) => {
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
setErr(message)
@ -1117,7 +1168,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</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
checked={!!value.enableConcurrentDownloadsLimit}
onChange={(checked) =>

View File

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

View File

@ -4,7 +4,7 @@
export type TaskItem = {
id: string
status: 'idle' | 'running' | 'done' | 'error' | 'cancelled'
status: 'idle' | 'queued' | 'running' | 'done' | 'error' | 'cancelled'
title: string
text?: string
done?: number
@ -60,26 +60,30 @@ export default function TaskList({ tasks, onCancel }: Props) {
const hasProgress = isRunning && (t.total ?? 0) > 0
const title = (t.title ?? '').trim()
const suffix = (t.text ?? '').trim()
const isQueued = t.status === 'queued'
const canCancel = (isRunning || isQueued) && t.cancellable && onCancel
return (
<div
key={t.id}
className={
"rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5 " +
"transition-opacity duration-500 " +
(t.fading ? "opacity-0" : "opacity-100")
'rounded-xl border border-gray-200 bg-white px-2 py-2 dark:border-white/10 dark:bg-white/5 ' +
'transition-opacity duration-500 ' +
(t.fading ? 'opacity-0' : 'opacity-100')
}
>
<div className="flex items-center gap-3">
{/* Left icon: Cancel X (running+cancellable) OR green check (done) */}
<div className="shrink-0">
{isRunning && t.cancellable && onCancel ? (
<div className="flex items-stretch gap-1 sm:min-h-[24px] sm:items-center">
{/* Left action / icon column */}
<div className="shrink-0 self-stretch flex">
{canCancel ? (
<button
type="button"
onClick={() => onCancel?.(t.id)}
className="inline-flex h-7 w-7 items-center justify-center rounded-md
className="
inline-flex w-9 items-center justify-center self-stretch rounded-lg
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"
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200
"
title="Abbrechen"
aria-label="Task abbrechen"
>
@ -87,29 +91,37 @@ export default function TaskList({ tasks, onCancel }: Props) {
</button>
) : t.status === 'done' ? (
<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"
aria-label="Fertig"
>
</span>
) : (
<span className="inline-block h-7 w-7" />
<span className="inline-flex w-9 self-stretch" />
)}
</div>
{/* One-line row: title (optional) + status + progress */}
<div className="min-w-0 flex-1 flex items-center gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
{/* Right content */}
<div className="min-w-0 flex-1 flex flex-col justify-center">
<div className="flex items-start gap-2 sm:items-center">
<div className="min-w-0 flex-1">
{/* Mobile: Title oben */}
<div className="sm:hidden truncate text-sm font-semibold text-gray-900 dark:text-white">
{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="font-normal text-gray-600 dark:text-gray-300">
{' · '}{suffix}
<span className="ml-1 font-normal text-gray-600 dark:text-gray-300">
· {suffix}
</span>
) : null}
</div>
</div>
{/* Status badge (running bewusst ohne Badge) */}
{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
@ -124,31 +136,36 @@ export default function TaskList({ tasks, onCancel }: Props) {
</span>
) : null}
{/* Spacer */}
<div className="flex-1" />
{/* 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>
) : null}
</div>
</div>
{/* Error text optional (unter der Zeile) */}
{/* Mobile: suffix unter dem Title */}
{suffix ? (
<div
className="mt-0.5 min-w-0 truncate text-xs text-gray-600 dark:text-gray-300 sm:hidden"
title={suffix}
>
{suffix}
</div>
) : null}
{t.status === 'error' && t.err ? (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
{t.err}
</div>
) : null}
</div>
</div>
</div>
)
})}
</div>

View File

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