bug fixes
This commit is contained in:
parent
e0b4cd589d
commit
3a6e1d0f48
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
"maxConcurrentDownloads": 80,
|
||||
"useChaturbateApi": true,
|
||||
"useMyFreeCamsWatcher": true,
|
||||
"autoDeleteSmallDownloads": false,
|
||||
"autoDeleteSmallDownloadsBelowMB": 100,
|
||||
"autoDeleteSmallDownloads": true,
|
||||
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||
"lowDiskPauseBelowGB": 5,
|
||||
"blurPreviews": false,
|
||||
"teaserPlayback": "all",
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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{}
|
||||
|
||||
33
backend/tasks_exclusive.go
Normal file
33
backend/tasks_exclusive.go
Normal 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:
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,30 +784,43 @@ 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
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user