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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueDeferredAssetsAndAI(job *RecordJob, videoPath, sourceURL string) bool {
|
func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string) bool {
|
||||||
videoPath = strings.TrimSpace(videoPath)
|
outPath = strings.TrimSpace(outPath)
|
||||||
if videoPath == "" {
|
if outPath == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
id := assetIDFromVideoPath(videoPath)
|
fileName := filepath.Base(outPath)
|
||||||
if id == "" {
|
assetID := assetIDFromVideoPath(outPath)
|
||||||
|
if assetID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
key := "enrich:" + id
|
truth := finishedPhaseTruthForID(assetID)
|
||||||
|
|
||||||
|
needMeta := !truth.MetaReady
|
||||||
|
needThumb := !truth.ThumbReady
|
||||||
|
needTeaser := !truth.TeaserReady
|
||||||
|
needSprites := !truth.SpritesReady
|
||||||
|
needAnalyze := !truth.AnalyzeReady
|
||||||
|
|
||||||
|
// Nichts zu tun -> erfolgreich
|
||||||
|
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur fehlende Phasen als queued publishen
|
||||||
|
if needMeta {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "queued", "Meta wartet", nil)
|
||||||
|
}
|
||||||
|
if needThumb {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "queued", "Vorschaubild wartet", nil)
|
||||||
|
}
|
||||||
|
if needTeaser {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "queued", "Teaser wartet", nil)
|
||||||
|
}
|
||||||
|
if needSprites {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
|
||||||
|
}
|
||||||
|
if needAnalyze {
|
||||||
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil)
|
||||||
|
}
|
||||||
|
|
||||||
ok := enrichQ.Enqueue(PostWorkTask{
|
ok := enrichQ.Enqueue(PostWorkTask{
|
||||||
Key: key,
|
Key: "enrich:" + assetID,
|
||||||
Added: time.Now(),
|
Added: time.Now(),
|
||||||
Run: func(ctx context.Context) error {
|
Run: func(ctx context.Context) error {
|
||||||
if job != nil {
|
return runDeferredEnrichPipeline(ctx, outPath, sourceURL)
|
||||||
st := enrichQ.StatusForKey(key)
|
|
||||||
publishFinishedPostworkStateForJob(
|
|
||||||
job,
|
|
||||||
"enrich",
|
|
||||||
st.State,
|
|
||||||
"assets",
|
|
||||||
"Wird verarbeitet…",
|
|
||||||
st.Position,
|
|
||||||
st.Waiting,
|
|
||||||
st.Running,
|
|
||||||
st.MaxParallel,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL)
|
|
||||||
|
|
||||||
if job != nil {
|
|
||||||
if err != nil {
|
|
||||||
publishFinishedPostworkStateForJob(
|
|
||||||
job,
|
|
||||||
"enrich",
|
|
||||||
"error",
|
|
||||||
"assets",
|
|
||||||
"Ergänzung fehlgeschlagen",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
publishFinishedPostworkStateForJob(
|
|
||||||
job,
|
|
||||||
"enrich",
|
|
||||||
"done",
|
|
||||||
"",
|
|
||||||
"Fertig",
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if ok && job != nil {
|
if ok {
|
||||||
st := enrichQ.StatusForKey(key)
|
return true
|
||||||
publishFinishedPostworkStateForJob(
|
|
||||||
job,
|
|
||||||
"enrich",
|
|
||||||
st.State,
|
|
||||||
"assets",
|
|
||||||
"Wartet",
|
|
||||||
st.Position,
|
|
||||||
st.Waiting,
|
|
||||||
st.Running,
|
|
||||||
st.MaxParallel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ok
|
enqueueErr := fmt.Errorf("enqueue failed")
|
||||||
|
|
||||||
|
// queued-Zustände auf error drehen, aber nur für wirklich fehlende Phasen
|
||||||
|
if needMeta {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta konnte nicht eingeplant werden", enqueueErr)
|
||||||
|
}
|
||||||
|
if needThumb {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild konnte nicht eingeplant werden", enqueueErr)
|
||||||
|
}
|
||||||
|
if needTeaser {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser konnte nicht eingeplant werden", enqueueErr)
|
||||||
|
}
|
||||||
|
if needSprites {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
|
||||||
|
}
|
||||||
|
if needAnalyze {
|
||||||
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.
|
// Core: generiert thumbs/preview/sprite/meta und sagt zurück was passiert ist.
|
||||||
|
|||||||
@ -2,66 +2,74 @@
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"modelKey": "jam_cream",
|
"modelKey": "jam_cream",
|
||||||
"url": "https://www.myfreecams.com/#jam_cream",
|
"url": "https://www.myfreecams.com/#Jam_cream",
|
||||||
"mode": "probe_retry",
|
"mode": "probe_retry",
|
||||||
"nextProbeAtMs": 1775738855792,
|
"nextProbeAtMs": 1775899166952,
|
||||||
"currentShow": "offline",
|
"currentShow": "offline",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "tashinadoncaster",
|
"modelKey": "milk_bunny_",
|
||||||
"url": "https://chaturbate.com/tashinadoncaster/",
|
"url": "https://chaturbate.com/milk_bunny_/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "hidden",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/tashinadoncaster.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "_jesslei_",
|
"modelKey": "kali_hiill",
|
||||||
"url": "https://chaturbate.com/_jesslei_/",
|
"url": "https://chaturbate.com/kali_hiill/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/_jesslei_.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "mary_meys",
|
"modelKey": "passiondesire_",
|
||||||
"url": "https://chaturbate.com/mary_meys/",
|
"url": "https://chaturbate.com/passiondesire_/",
|
||||||
"mode": "wait_public",
|
|
||||||
"currentShow": "private",
|
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/mary_meys.jpg",
|
|
||||||
"source": "watched"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"modelKey": "jodeesolarz",
|
|
||||||
"url": "https://chaturbate.com/jodeesolarz/",
|
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "away",
|
"currentShow": "away",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/jodeesolarz.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "di_n_alex",
|
"modelKey": "tsukuyami_mi",
|
||||||
"url": "https://chaturbate.com/di_n_alex/",
|
"url": "https://chaturbate.com/tsukuyami_mi/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "hidden",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "swtty_meivi",
|
||||||
|
"url": "https://chaturbate.com/swtty_meivi/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/di_n_alex.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "lora_lifelover",
|
"modelKey": "amandatalk",
|
||||||
"url": "https://chaturbate.com/lora_lifelover/",
|
"url": "https://chaturbate.com/amandatalk/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "hidden",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "chaesoffel",
|
"modelKey": "oliver_and_megan",
|
||||||
"url": "https://chaturbate.com/chaesoffel/",
|
"url": "https://chaturbate.com/oliver_and_megan/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "hidden",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/chaesoffel.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "aurora_asti_",
|
||||||
|
"url": "https://chaturbate.com/aurora_asti_/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -73,26 +81,34 @@
|
|||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "shirleendurrah",
|
"modelKey": "gimbobar",
|
||||||
"url": "https://chaturbate.com/shirleendurrah/",
|
"url": "https://chaturbate.com/gimbobar/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "private",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/shirleendurrah.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "palmiratownzen",
|
"modelKey": "lizbethbiers",
|
||||||
"url": "https://chaturbate.com/palmiratownzen/",
|
"url": "https://chaturbate.com/lizbethbiers/",
|
||||||
"mode": "wait_public",
|
"mode": "wait_public",
|
||||||
"currentShow": "away",
|
"currentShow": "private",
|
||||||
"imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg",
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"modelKey": "rafaelaloghry",
|
"modelKey": "bonnettbonett",
|
||||||
"url": "https://chaturbate.com/rafaelaloghry/",
|
"url": "https://chaturbate.com/bonnettbonett/",
|
||||||
|
"mode": "wait_public",
|
||||||
|
"currentShow": "private",
|
||||||
|
"imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg",
|
||||||
|
"source": "watched"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"modelKey": "pazrapacki",
|
||||||
|
"url": "https://chaturbate.com/pazrapacki/",
|
||||||
"mode": "probe_retry",
|
"mode": "probe_retry",
|
||||||
"nextProbeAtMs": 1775738868799,
|
"nextProbeAtMs": 1775899181131,
|
||||||
"currentShow": "unknown",
|
"currentShow": "unknown",
|
||||||
"source": "watched"
|
"source": "watched"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,10 +141,15 @@ type modelsOverviewRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type modelsOverviewModel struct {
|
type modelsOverviewModel struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Input string `json:"input"`
|
Input string `json:"input"`
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
ModelKey string `json:"modelKey"`
|
ModelKey string `json:"modelKey"`
|
||||||
|
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Sex string `json:"sex,omitempty"`
|
||||||
|
Subgender string `json:"subgender,omitempty"`
|
||||||
|
|
||||||
Watching bool `json:"watching"`
|
Watching bool `json:"watching"`
|
||||||
Favorite bool `json:"favorite,omitempty"`
|
Favorite bool `json:"favorite,omitempty"`
|
||||||
Liked *bool `json:"liked,omitempty"`
|
Liked *bool `json:"liked,omitempty"`
|
||||||
|
|||||||
@ -17,12 +17,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type StoredModel struct {
|
type StoredModel struct {
|
||||||
ID string `json:"id"` // unique (wir verwenden host:modelKey)
|
ID string `json:"id"` // unique (wir verwenden host:modelKey)
|
||||||
Input string `json:"input"` // Original-URL/Eingabe
|
Input string `json:"input"` // Original-URL/Eingabe
|
||||||
IsURL bool `json:"isUrl"` // vom Parser
|
IsURL bool `json:"isUrl"` // vom Parser
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
ModelKey string `json:"modelKey"` // Display/Key
|
ModelKey string `json:"modelKey"` // Display/Key
|
||||||
|
|
||||||
|
Gender string `json:"gender,omitempty"`
|
||||||
|
Sex string `json:"sex,omitempty"`
|
||||||
|
Subgender string `json:"subgender,omitempty"`
|
||||||
|
|
||||||
Tags string `json:"tags,omitempty"`
|
Tags string `json:"tags,omitempty"`
|
||||||
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
|
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
|
||||||
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
||||||
@ -1048,13 +1053,27 @@ ORDER BY updated_at DESC;
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var gender, sex, subgender string
|
||||||
|
|
||||||
|
if strings.TrimSpace(cbOnlineJSON) != "" {
|
||||||
|
var snap ChaturbateOnlineSnapshot
|
||||||
|
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
|
||||||
|
gender = strings.TrimSpace(snap.Gender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m := StoredModel{
|
m := StoredModel{
|
||||||
ID: id,
|
ID: id,
|
||||||
Input: input,
|
Input: input,
|
||||||
IsURL: isURL,
|
IsURL: isURL,
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: path,
|
Path: path,
|
||||||
ModelKey: modelKey,
|
ModelKey: modelKey,
|
||||||
|
|
||||||
|
Gender: gender,
|
||||||
|
Sex: sex,
|
||||||
|
Subgender: subgender,
|
||||||
|
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
LastStream: fmtNullTime(lastStream),
|
LastStream: fmtNullTime(lastStream),
|
||||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||||
@ -1870,13 +1889,26 @@ WHERE id=$1;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var gender, sex, subgender string
|
||||||
|
|
||||||
|
if strings.TrimSpace(cbOnlineJSON) != "" {
|
||||||
|
var snap ChaturbateOnlineSnapshot
|
||||||
|
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
|
||||||
|
gender = strings.TrimSpace(snap.Gender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m := StoredModel{
|
m := StoredModel{
|
||||||
ID: id,
|
ID: id,
|
||||||
Input: input,
|
Input: input,
|
||||||
IsURL: isURL,
|
IsURL: isURL,
|
||||||
Host: host,
|
Host: host,
|
||||||
Path: path,
|
Path: path,
|
||||||
ModelKey: modelKey,
|
ModelKey: modelKey,
|
||||||
|
|
||||||
|
Gender: gender,
|
||||||
|
Sex: sex,
|
||||||
|
Subgender: subgender,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
LastStream: fmtNullTime(lastStream),
|
LastStream: fmtNullTime(lastStream),
|
||||||
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
|
||||||
|
|||||||
@ -10,6 +10,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func normalizeMFCUserKey(s string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
func mfcUserFromURL(raw string) string {
|
func mfcUserFromURL(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@ -42,7 +46,7 @@ func mfcUserFromURL(raw string) string {
|
|||||||
for i := len(parts) - 1; i >= 0; i-- {
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
p := strings.TrimSpace(parts[i])
|
p := strings.TrimSpace(parts[i])
|
||||||
if p != "" {
|
if p != "" {
|
||||||
return strings.ToLower(p)
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,42 +55,40 @@ func mfcUserFromURL(raw string) string {
|
|||||||
for i := len(parts) - 1; i >= 0; i-- {
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
p := strings.TrimSpace(parts[i])
|
p := strings.TrimSpace(parts[i])
|
||||||
if p != "" {
|
if p != "" {
|
||||||
return strings.ToLower(p)
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
|
func mfcUserKeyFromURL(raw string) string {
|
||||||
key := strings.ToLower(strings.TrimSpace(m.ModelKey))
|
return normalizeMFCUserKey(mfcUserFromURL(raw))
|
||||||
if key != "" {
|
}
|
||||||
return key
|
|
||||||
}
|
|
||||||
|
|
||||||
in := strings.TrimSpace(m.Input)
|
func watchedMyFreeCamsUsername(m WatchedModelLite) string {
|
||||||
if in == "" {
|
if u := mfcUserFromURL(m.Input); u != "" {
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if u := mfcUserFromURL(in); u != "" {
|
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.ToLower(strings.Trim(in, "/"))
|
if key := strings.TrimSpace(m.ModelKey); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(strings.Trim(m.Input, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
|
||||||
|
return normalizeMFCUserKey(watchedMyFreeCamsUsername(m))
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveMyFreeCamsURL(m WatchedModelLite) string {
|
func resolveMyFreeCamsURL(m WatchedModelLite) string {
|
||||||
if u := mfcUserFromURL(m.Input); u != "" {
|
user := watchedMyFreeCamsUsername(m)
|
||||||
return fmt.Sprintf("https://www.myfreecams.com/#%s", u)
|
if user == "" {
|
||||||
}
|
|
||||||
|
|
||||||
key := watchedMyFreeCamsUserKey(m)
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("https://www.myfreecams.com/#%s", key)
|
return fmt.Sprintf("https://www.myfreecams.com/#%s", user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Startet watched MyFreeCams Models (ohne API) "best-effort".
|
// Startet watched MyFreeCams Models (ohne API) "best-effort".
|
||||||
@ -156,9 +158,9 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
manualOrder := make([]string, 0, len(manualItems))
|
manualOrder := make([]string, 0, len(manualItems))
|
||||||
|
|
||||||
for _, it := range manualItems {
|
for _, it := range manualItems {
|
||||||
key := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
key := normalizeMFCUserKey(strings.TrimSpace(it.ModelKey))
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = mfcUserFromURL(it.URL)
|
key = mfcUserKeyFromURL(it.URL)
|
||||||
}
|
}
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
@ -211,7 +213,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
u := mfcUserFromURL(j.SourceURL)
|
u := mfcUserKeyFromURL(j.SourceURL)
|
||||||
if u == "" {
|
if u == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@ -354,7 +354,6 @@ func startEnrichStatusRefresher() {
|
|||||||
key := "enrich:" + id
|
key := "enrich:" + id
|
||||||
st := enrichQ.StatusForKey(key)
|
st := enrichQ.StatusForKey(key)
|
||||||
|
|
||||||
// Nur senden, wenn der Job tatsächlich gerade queued/running ist
|
|
||||||
if st.State != "queued" && st.State != "running" {
|
if st.State != "queued" && st.State != "running" {
|
||||||
clearEnrichStatusSnapshot(key)
|
clearEnrichStatusSnapshot(key)
|
||||||
continue
|
continue
|
||||||
@ -364,22 +363,10 @@ func startEnrichStatusRefresher() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
label := "Wartet"
|
// Wichtig:
|
||||||
if st.State == "running" {
|
// Hier NICHT mehr pauschal "analyze" publishen.
|
||||||
label = "Wird verarbeitet…"
|
// Die echten Phasen kommen direkt aus dem Enrich-Worker selbst:
|
||||||
}
|
// meta/thumb/teaser (nur falls fehlend), danach sprites, danach analyze.
|
||||||
|
|
||||||
publishFinishedPostworkStateForJob(
|
|
||||||
job,
|
|
||||||
"enrich",
|
|
||||||
st.State,
|
|
||||||
"assets",
|
|
||||||
label,
|
|
||||||
st.Position,
|
|
||||||
st.Waiting,
|
|
||||||
st.Running,
|
|
||||||
st.MaxParallel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -1532,36 +1533,117 @@ func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *
|
|||||||
servePreviewStatusSVG(w, label, status)
|
servePreviewStatusSVG(w, label, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func serveRunningJobPreview(w http.ResponseWriter, r *http.Request, job *RecordJob) bool {
|
||||||
|
if job == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := detectPreviewProvider(job, job.Output)
|
||||||
|
previewState := previewStateForJob(job)
|
||||||
|
imageURL := strings.TrimSpace(previewImageURLForJob(job))
|
||||||
|
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
|
||||||
|
previewCookie := strings.TrimSpace(job.PreviewCookie)
|
||||||
|
previewUA := strings.TrimSpace(job.PreviewUA)
|
||||||
|
outPath := strings.TrimSpace(job.Output)
|
||||||
|
|
||||||
|
// Chaturbate: immer CDN bevorzugen, nichts auf Platte schreiben
|
||||||
|
if provider == "chaturbate" && imageURL != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if img, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(img) > 0 {
|
||||||
|
job.previewMu.Lock()
|
||||||
|
job.previewJPG = append([]byte(nil), img...)
|
||||||
|
job.previewJPGAt = time.Now()
|
||||||
|
job.previewMu.Unlock()
|
||||||
|
|
||||||
|
serveLivePreviewJPGBytes(w, img)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
job.previewMu.Lock()
|
||||||
|
cached := append([]byte(nil), job.previewJPG...)
|
||||||
|
cachedAt := job.previewJPGAt
|
||||||
|
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 18*time.Second
|
||||||
|
canSpawn := !fresh && !job.previewGen && provider != "chaturbate"
|
||||||
|
if canSpawn {
|
||||||
|
job.previewGen = true
|
||||||
|
}
|
||||||
|
job.previewMu.Unlock()
|
||||||
|
|
||||||
|
if fresh || (provider == "chaturbate" && len(cached) > 0) {
|
||||||
|
serveLivePreviewJPGBytes(w, cached)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für Nicht-Chaturbate darf weiterhin in-memory erzeugt werden, aber nicht als preview.jpg speichern
|
||||||
|
if canSpawn {
|
||||||
|
go func(j *RecordJob, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath string) {
|
||||||
|
defer func() {
|
||||||
|
j.previewMu.Lock()
|
||||||
|
j.previewGen = false
|
||||||
|
j.previewMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var img []byte
|
||||||
|
|
||||||
|
if previewState == "public" && imageURL != "" {
|
||||||
|
if b, err := downloadPreviewImageJPG(context.Background(), imageURL, previewUA); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 && previewM3U8 != "" {
|
||||||
|
if b, err := extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 && outPath != "" {
|
||||||
|
if b, err := extractLastFrameJPGScaled(outPath, 720, 75); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
j.previewMu.Lock()
|
||||||
|
j.previewJPG = append([]byte(nil), img...)
|
||||||
|
j.previewJPGAt = time.Now()
|
||||||
|
j.previewMu.Unlock()
|
||||||
|
}(job, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
|
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
job := jobs[id]
|
job := jobs[id]
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
|
if job.Status == JobRunning {
|
||||||
|
if serveRunningJobPreview(w, r, job) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
assetID := assetIDForJob(job)
|
assetID := assetIDForJob(job)
|
||||||
if assetID != "" {
|
if assetID != "" {
|
||||||
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
||||||
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||||
if job.Status == JobRunning {
|
servePreviewJPGFile(w, r, jpgPath)
|
||||||
serveLivePreviewJPGFile(w, r, jpgPath)
|
|
||||||
} else {
|
|
||||||
servePreviewJPGFile(w, r, jpgPath)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if job.Status == JobRunning {
|
|
||||||
job.previewMu.Lock()
|
|
||||||
cached := job.previewJPG
|
|
||||||
job.previewMu.Unlock()
|
|
||||||
if len(cached) > 0 {
|
|
||||||
serveLivePreviewJPGBytes(w, cached)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1634,6 +1716,14 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
low := strings.ToLower(strings.TrimSpace(file))
|
low := strings.ToLower(strings.TrimSpace(file))
|
||||||
|
|
||||||
if low == "preview.jpg" {
|
if low == "preview.jpg" {
|
||||||
|
jobsMu.RLock()
|
||||||
|
job := jobs[id]
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
if job != nil && job.Status == JobRunning {
|
||||||
|
startLiveThumbJPGLoop(context.Background(), job)
|
||||||
|
}
|
||||||
|
|
||||||
servePreviewJPGAlias(w, r, id)
|
servePreviewJPGAlias(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1649,82 +1739,34 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
if job.Status == JobRunning {
|
if job.Status == JobRunning {
|
||||||
assetID := assetIDForJob(job)
|
if serveRunningJobPreview(w, r, job) {
|
||||||
if assetID != "" {
|
return
|
||||||
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
}
|
||||||
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
|
||||||
serveLivePreviewJPGFile(w, r, jpgPath)
|
state := strings.TrimSpace(previewStateForJob(job))
|
||||||
return
|
if state == "private" {
|
||||||
}
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state == "offline" {
|
||||||
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetID := assetIDForJob(job)
|
||||||
|
if assetID != "" {
|
||||||
|
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
|
||||||
|
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||||||
|
servePreviewJPGFile(w, r, jpgPath)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
job.previewMu.Lock()
|
|
||||||
cached := job.previewJPG
|
|
||||||
cachedAt := job.previewJPGAt
|
|
||||||
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 8*time.Second
|
|
||||||
|
|
||||||
if !fresh && !job.previewGen {
|
|
||||||
job.previewGen = true
|
|
||||||
go func(j *RecordJob) {
|
|
||||||
defer func() {
|
|
||||||
j.previewMu.Lock()
|
|
||||||
j.previewGen = false
|
|
||||||
j.previewMu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var img []byte
|
|
||||||
var genErr error
|
|
||||||
|
|
||||||
previewM3U8 := strings.TrimSpace(j.PreviewM3U8)
|
|
||||||
previewCookie := strings.TrimSpace(j.PreviewCookie)
|
|
||||||
previewUA := strings.TrimSpace(j.PreviewUA)
|
|
||||||
|
|
||||||
if previewM3U8 != "" {
|
|
||||||
img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA)
|
|
||||||
if genErr != nil {
|
|
||||||
// optional loggen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(img) == 0 {
|
|
||||||
outPath := strings.TrimSpace(j.Output)
|
|
||||||
if outPath != "" {
|
|
||||||
img, _ = extractLastFrameJPGScaled(outPath, 720, 75)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(img) > 0 {
|
|
||||||
j.previewMu.Lock()
|
|
||||||
j.previewJPG = img
|
|
||||||
j.previewJPGAt = time.Now()
|
|
||||||
j.previewMu.Unlock()
|
|
||||||
}
|
|
||||||
}(job)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := cached
|
|
||||||
job.previewMu.Unlock()
|
|
||||||
|
|
||||||
if len(out) > 0 {
|
|
||||||
serveLivePreviewJPGBytes(w, out)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobsMu.RLock()
|
|
||||||
state := strings.TrimSpace(job.PreviewState)
|
|
||||||
jobsMu.RUnlock()
|
|
||||||
|
|
||||||
if state == "private" {
|
|
||||||
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if state == "offline" {
|
|
||||||
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1733,6 +1775,210 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
servePreviewForFinishedFile(w, r, id)
|
servePreviewForFinishedFile(w, r, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reflectStringField(v any, names ...string) string {
|
||||||
|
rv := reflect.ValueOf(v)
|
||||||
|
if !rv.IsValid() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if rv.Kind() == reflect.Pointer {
|
||||||
|
if rv.IsNil() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rv = rv.Elem()
|
||||||
|
}
|
||||||
|
if !rv.IsValid() || rv.Kind() != reflect.Struct {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
f := rv.FieldByName(name)
|
||||||
|
if !f.IsValid() || f.Kind() != reflect.String {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(f.String())
|
||||||
|
if s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferModelFromSourceURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www."))
|
||||||
|
switch {
|
||||||
|
case strings.Contains(host, "chaturbate.com"):
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
}
|
||||||
|
case strings.Contains(host, "myfreecams.com"):
|
||||||
|
hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/")
|
||||||
|
if hash != "" {
|
||||||
|
parts := strings.Split(hash, "/")
|
||||||
|
last := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if last != "" {
|
||||||
|
return strings.ToLower(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
last := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if last != "" {
|
||||||
|
return strings.ToLower(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewModelKeyForJob(job *RecordJob) string {
|
||||||
|
if job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(job, "Model", "ModelKey"); s != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(job, "SourceURL", "SourceUrl"); s != "" {
|
||||||
|
if mk := inferModelFromSourceURL(s); mk != "" {
|
||||||
|
return mk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(job, "Output"); s != "" {
|
||||||
|
if mk := inferModelFromThumbLike(s); mk != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(mk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewStateForJob(job *RecordJob) string {
|
||||||
|
if job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(job,
|
||||||
|
"PreviewState",
|
||||||
|
"RoomStatus",
|
||||||
|
"ModelStatus",
|
||||||
|
"CurrentShow",
|
||||||
|
"Show",
|
||||||
|
"StatusText",
|
||||||
|
); s != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
modelKey := previewModelKeyForJob(job)
|
||||||
|
if modelKey == "" || coverModelStore == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
models := coverModelStore.List()
|
||||||
|
for _, m := range models {
|
||||||
|
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
|
||||||
|
if mk == "" || mk != modelKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(m,
|
||||||
|
"RoomStatus",
|
||||||
|
"CurrentShow",
|
||||||
|
"Status",
|
||||||
|
"ModelStatus",
|
||||||
|
); s != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewImageURLForJob(job *RecordJob) string {
|
||||||
|
if job == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(job,
|
||||||
|
"ModelImageURL",
|
||||||
|
"ModelImageUrl",
|
||||||
|
"ImageURL",
|
||||||
|
"ImageUrl",
|
||||||
|
"PreviewImageURL",
|
||||||
|
"PreviewImageUrl",
|
||||||
|
); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
modelKey := previewModelKeyForJob(job)
|
||||||
|
if modelKey == "" || coverModelStore == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
models := coverModelStore.List()
|
||||||
|
for _, m := range models {
|
||||||
|
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
|
||||||
|
if mk == "" || mk != modelKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := reflectStringField(m,
|
||||||
|
"ImageURL",
|
||||||
|
"ImageUrl",
|
||||||
|
"ModelImageURL",
|
||||||
|
"ModelImageUrl",
|
||||||
|
); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageBytesToJPG(raw []byte, quality int) ([]byte, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty image")
|
||||||
|
}
|
||||||
|
if quality <= 0 || quality > 100 {
|
||||||
|
quality = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
return nil, fmt.Errorf("jpeg encode returned empty data")
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadPreviewImageJPG(ctx context.Context, rawURL, ua string) ([]byte, error) {
|
||||||
|
raw, _, err := downloadBytes(ctx, rawURL, ua)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return imageBytesToJPG(raw, 82)
|
||||||
|
}
|
||||||
|
|
||||||
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
status := job.Status
|
status := job.Status
|
||||||
@ -1746,21 +1992,16 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
assetID := assetIDForJob(job)
|
job.previewMu.Lock()
|
||||||
thumbPath, err := generatedThumbJPGFile(assetID)
|
lastAt := job.previewJPGAt
|
||||||
if err != nil {
|
job.previewMu.Unlock()
|
||||||
|
|
||||||
|
if !lastAt.IsZero() && time.Since(lastAt) < 18*time.Second {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
|
|
||||||
// etwas Puffer, damit die 10s-Loop nicht an exakten Grenzwerten hängen bleibt
|
|
||||||
if time.Since(st.ModTime()) < 9*time.Second {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if thumbSem != nil {
|
if thumbSem != nil {
|
||||||
thumbCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
thumbCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := thumbSem.Acquire(thumbCtx); err != nil {
|
if err := thumbSem.Acquire(thumbCtx); err != nil {
|
||||||
return
|
return
|
||||||
@ -1770,15 +2011,33 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
|||||||
|
|
||||||
var img []byte
|
var img []byte
|
||||||
|
|
||||||
if previewM3U8 != "" {
|
provider := detectPreviewProvider(job, out)
|
||||||
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
|
previewState := previewStateForJob(job)
|
||||||
img = b
|
imageURL := strings.TrimSpace(previewImageURLForJob(job))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(img) == 0 && out != "" {
|
if provider == "chaturbate" {
|
||||||
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
if imageURL != "" {
|
||||||
img = b
|
if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if previewState == "public" && imageURL != "" {
|
||||||
|
if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 && previewM3U8 != "" {
|
||||||
|
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(img) == 0 && out != "" {
|
||||||
|
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
|
||||||
|
img = b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1786,7 +2045,10 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = atomicWriteFile(thumbPath, img)
|
job.previewMu.Lock()
|
||||||
|
job.previewJPG = append([]byte(nil), img...)
|
||||||
|
job.previewJPGAt = time.Now()
|
||||||
|
job.previewMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
|
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
|
||||||
@ -1804,7 +2066,7 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(20 * time.Second):
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
st := job.Status
|
st := job.Status
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|||||||
@ -91,11 +91,6 @@ func RecordStream(
|
|||||||
job.PreviewCookie = httpCookie
|
job.PreviewCookie = httpCookie
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
// Live-Thumbs sind teuer: bei vielen parallelen Downloads nicht für alle starten.
|
|
||||||
if assetID != "" && activeRecordingJobsCount() <= 2 {
|
|
||||||
startLiveThumbJPGLoop(ctx, job)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxPlaylistRefreshes = 12
|
const maxPlaylistRefreshes = 12
|
||||||
|
|||||||
@ -1159,6 +1159,167 @@ func buildPostworkPhaseUpdater(job *RecordJob, postKey string, out *string) func
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishDeferredPhase(fileName, assetID, queue, phase, state, label string, err error) {
|
||||||
|
errText := ""
|
||||||
|
if err != nil {
|
||||||
|
errText = strings.TrimSpace(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
publishFinishedPostworkPhase(
|
||||||
|
fileName,
|
||||||
|
assetID,
|
||||||
|
queue,
|
||||||
|
phase,
|
||||||
|
state,
|
||||||
|
label,
|
||||||
|
errText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL string) error {
|
||||||
|
outPath = strings.TrimSpace(outPath)
|
||||||
|
if outPath == "" {
|
||||||
|
return fmt.Errorf("output path leer")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := filepath.Base(outPath)
|
||||||
|
assetID := assetIDFromVideoPath(outPath)
|
||||||
|
if assetID == "" {
|
||||||
|
return fmt.Errorf("asset id fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// best effort sourceURL aus meta.json nachziehen, falls leer
|
||||||
|
if strings.TrimSpace(sourceURL) == "" {
|
||||||
|
if _, _, _, _, metaPath, err := assetPathsForID(assetID); err == nil {
|
||||||
|
if fi, serr := os.Stat(outPath); serr == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
if u, ok := readVideoMetaSourceURL(metaPath, fi); ok {
|
||||||
|
sourceURL = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abortIfCanceled := func(stepErr error, queue string, phase string) error {
|
||||||
|
if errors.Is(stepErr, context.Canceled) ||
|
||||||
|
errors.Is(stepErr, context.DeadlineExceeded) ||
|
||||||
|
ctx.Err() != nil {
|
||||||
|
publishDeferredPhase(fileName, assetID, queue, phase, "missing", "", nil)
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
truth := finishedPhaseTruthForID(assetID)
|
||||||
|
|
||||||
|
// -------------------------------------------------
|
||||||
|
// Fehlende Vorstufen nur nachziehen, NICHT alles neu
|
||||||
|
// -------------------------------------------------
|
||||||
|
|
||||||
|
if !truth.MetaReady {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "running", "Meta", nil)
|
||||||
|
|
||||||
|
okMeta, err := ensureMetaForVideoCtx(ctx, outPath, sourceURL)
|
||||||
|
if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("meta konnte nicht erzeugt werden")
|
||||||
|
}
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "error", "Meta fehlgeschlagen", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil)
|
||||||
|
truth = finishedPhaseTruthForID(assetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !truth.ThumbReady {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "running", "Vorschaubild", nil)
|
||||||
|
|
||||||
|
_, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, outPath, sourceURL, nil)
|
||||||
|
if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden")
|
||||||
|
}
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil)
|
||||||
|
truth = finishedPhaseTruthForID(assetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !truth.TeaserReady {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "running", "Teaser", nil)
|
||||||
|
|
||||||
|
_, err := ensureTeaserForVideoCtx(ctx, outPath, sourceURL)
|
||||||
|
if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("teaser konnte nicht erzeugt werden")
|
||||||
|
}
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "error", "Teaser fehlgeschlagen", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil)
|
||||||
|
truth = finishedPhaseTruthForID(assetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier erst die langsamen Hintergrundphasen
|
||||||
|
var firstErr error
|
||||||
|
|
||||||
|
if !truth.SpritesReady {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "running", "Sprites", nil)
|
||||||
|
|
||||||
|
_, err := ensureSpritesForVideoCtx(ctx, outPath, sourceURL)
|
||||||
|
if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("sprites konnten nicht erzeugt werden")
|
||||||
|
}
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites fehlgeschlagen", err)
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
truth = finishedPhaseTruthForID(assetID)
|
||||||
|
|
||||||
|
if !truth.AnalyzeReady {
|
||||||
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil)
|
||||||
|
|
||||||
|
_, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw")
|
||||||
|
if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil {
|
||||||
|
return cerr
|
||||||
|
}
|
||||||
|
if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady {
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden")
|
||||||
|
}
|
||||||
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err)
|
||||||
|
if firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) {
|
func handlePrimaryPostworkFailure(job *RecordJob, out string, postTarget JobStatus, err error) {
|
||||||
finalStatus := JobFailed
|
finalStatus := JobFailed
|
||||||
if postTarget == JobStopped {
|
if postTarget == JobStopped {
|
||||||
@ -1223,15 +1384,6 @@ func handlePrimaryPostworkSuccess(job *RecordJob, out string, postTarget JobStat
|
|||||||
|
|
||||||
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
|
if ok := enqueueDeferredAssetsAndAI(job, out, job.SourceURL); !ok {
|
||||||
fmt.Println("⚠️ deferred enrichment enqueue failed:", out)
|
fmt.Println("⚠️ deferred enrichment enqueue failed:", out)
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
|
||||||
File: finalFile,
|
|
||||||
AssetID: finalAssetID,
|
|
||||||
Queue: "enrich",
|
|
||||||
State: "error",
|
|
||||||
Phase: "assets",
|
|
||||||
Label: "Späte Nachbearbeitung konnte nicht gestartet werden",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if postTarget == JobStopped {
|
if postTarget == JobStopped {
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
"maxConcurrentDownloads": 80,
|
"maxConcurrentDownloads": 80,
|
||||||
"useChaturbateApi": true,
|
"useChaturbateApi": true,
|
||||||
"useMyFreeCamsWatcher": true,
|
"useMyFreeCamsWatcher": true,
|
||||||
"autoDeleteSmallDownloads": false,
|
"autoDeleteSmallDownloads": true,
|
||||||
"autoDeleteSmallDownloadsBelowMB": 100,
|
"autoDeleteSmallDownloadsBelowMB": 300,
|
||||||
"lowDiskPauseBelowGB": 5,
|
"lowDiskPauseBelowGB": 5,
|
||||||
"blurPreviews": false,
|
"blurPreviews": false,
|
||||||
"teaserPlayback": "all",
|
"teaserPlayback": "all",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
||||||
type AssetsTaskState struct {
|
type AssetsTaskState struct {
|
||||||
|
Queued bool `json:"queued"`
|
||||||
Running bool `json:"running"`
|
Running bool `json:"running"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Done int `json:"done"`
|
Done int `json:"done"`
|
||||||
@ -31,6 +32,7 @@ type AssetsTaskState struct {
|
|||||||
CurrentQueue string `json:"currentQueue,omitempty"`
|
CurrentQueue string `json:"currentQueue,omitempty"`
|
||||||
CurrentPhase string `json:"currentPhase,omitempty"`
|
CurrentPhase string `json:"currentPhase,omitempty"`
|
||||||
CurrentLabel string `json:"currentLabel,omitempty"`
|
CurrentLabel string `json:"currentLabel,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var assetsTaskMu sync.Mutex
|
var assetsTaskMu sync.Mutex
|
||||||
@ -71,32 +73,31 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
assetsTaskMu.Lock()
|
assetsTaskMu.Lock()
|
||||||
if assetsTaskState.Running {
|
if assetsTaskState.Running || assetsTaskState.Queued {
|
||||||
st := assetsTaskState
|
st := assetsTaskState
|
||||||
assetsTaskMu.Unlock()
|
assetsTaskMu.Unlock()
|
||||||
writeJSON(w, http.StatusOK, st)
|
writeJSON(w, http.StatusOK, st)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// cancelbarer Context (pro Run)
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
assetsTaskCancel = cancel
|
assetsTaskCancel = cancel
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
assetsTaskState = AssetsTaskState{
|
assetsTaskState = AssetsTaskState{
|
||||||
Running: true,
|
Queued: true,
|
||||||
|
Running: false,
|
||||||
Total: 0,
|
Total: 0,
|
||||||
Done: 0,
|
Done: 0,
|
||||||
GeneratedThumbs: 0,
|
GeneratedThumbs: 0,
|
||||||
GeneratedPreviews: 0,
|
GeneratedPreviews: 0,
|
||||||
Skipped: 0,
|
Skipped: 0,
|
||||||
StartedAt: now,
|
StartedAt: time.Time{},
|
||||||
FinishedAt: nil,
|
FinishedAt: nil,
|
||||||
Error: "",
|
Error: "",
|
||||||
CurrentFile: "",
|
CurrentFile: "",
|
||||||
CurrentQueue: "",
|
CurrentQueue: "",
|
||||||
CurrentPhase: "",
|
CurrentPhase: "",
|
||||||
CurrentLabel: "",
|
CurrentLabel: "",
|
||||||
|
Text: "Wartet…",
|
||||||
}
|
}
|
||||||
st := assetsTaskState
|
st := assetsTaskState
|
||||||
assetsTaskMu.Unlock()
|
assetsTaskMu.Unlock()
|
||||||
@ -104,7 +105,32 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
notifyAssetsChanged()
|
notifyAssetsChanged()
|
||||||
publishTaskState()
|
publishTaskState()
|
||||||
|
|
||||||
go runGenerateMissingAssets(ctx)
|
go func() {
|
||||||
|
if err := acquireExclusiveTask(ctx); err != nil {
|
||||||
|
now := time.Now()
|
||||||
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = false
|
||||||
|
st.FinishedAt = &now
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
st.Error = "Abgebrochen"
|
||||||
|
} else {
|
||||||
|
st.Error = err.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseExclusiveTask()
|
||||||
|
|
||||||
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = true
|
||||||
|
st.StartedAt = time.Now()
|
||||||
|
st.Text = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
runGenerateMissingAssets(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, st)
|
writeJSON(w, http.StatusOK, st)
|
||||||
return
|
return
|
||||||
@ -113,9 +139,10 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
assetsTaskMu.Lock()
|
assetsTaskMu.Lock()
|
||||||
cancel := assetsTaskCancel
|
cancel := assetsTaskCancel
|
||||||
running := assetsTaskState.Running
|
running := assetsTaskState.Running
|
||||||
|
queued := assetsTaskState.Queued
|
||||||
assetsTaskMu.Unlock()
|
assetsTaskMu.Unlock()
|
||||||
|
|
||||||
if !running || cancel == nil {
|
if (!running && !queued) || cancel == nil {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -126,7 +153,9 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
|
// UI sofort informieren (ohne Running künstlich auf false zu setzen —
|
||||||
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
|
// das macht der Worker zuverlässig im finishWithErr(context.Canceled))
|
||||||
st := updateAssetsState(func(st *AssetsTaskState) {
|
st := updateAssetsState(func(st *AssetsTaskState) {
|
||||||
if st.Running {
|
if st.Running || st.Queued {
|
||||||
|
st.Queued = false
|
||||||
|
st.Running = false
|
||||||
st.Error = "Abgebrochen"
|
st.Error = "Abgebrochen"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -189,6 +218,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
updateAssetsState(func(st *AssetsTaskState) {
|
updateAssetsState(func(st *AssetsTaskState) {
|
||||||
|
st.Queued = false
|
||||||
st.Running = false
|
st.Running = false
|
||||||
st.FinishedAt = &now
|
st.FinishedAt = &now
|
||||||
st.CurrentFile = ""
|
st.CurrentFile = ""
|
||||||
|
|||||||
@ -90,6 +90,13 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
mb = 0
|
mb = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind
|
||||||
|
if err := acquireExclusiveTask(r.Context()); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusRequestTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer releaseExclusiveTask()
|
||||||
|
|
||||||
setCleanupTaskRunning("Räume auf…")
|
setCleanupTaskRunning("Räume auf…")
|
||||||
|
|
||||||
resp := cleanupResp{}
|
resp := cleanupResp{}
|
||||||
|
|||||||
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
job.State = "running"
|
|
||||||
job.StartedAt = time.Now()
|
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob {
|
||||||
|
regenerateAssetsMu.Lock()
|
||||||
|
defer regenerateAssetsMu.Unlock()
|
||||||
|
|
||||||
|
job := regenerateAssetsJobs[file]
|
||||||
|
if job == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if job.State != "queued" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
job.State = "running"
|
||||||
|
job.StartedAt = time.Now()
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
func finishRegenerateAssetsJob(file string, state string, errMsg string) {
|
func finishRegenerateAssetsJob(file string, state string, errMsg string) {
|
||||||
regenerateAssetsMu.Lock()
|
regenerateAssetsMu.Lock()
|
||||||
defer regenerateAssetsMu.Unlock()
|
defer regenerateAssetsMu.Unlock()
|
||||||
@ -305,7 +320,24 @@ func regenerateAssetsWorker() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
runRegenerateAssetsJob(job)
|
// ✅ wartet global, bis kein anderer exklusiver Task läuft
|
||||||
|
if err := acquireExclusiveTask(job.Ctx); err != nil {
|
||||||
|
finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen")
|
||||||
|
setRegenerateAssetsTaskError(job.File, "Abgebrochen")
|
||||||
|
removeRegenerateAssetsJobLater(job.File, 2*time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
job = markRegenerateAssetsJobRunning(job.File)
|
||||||
|
if job == nil {
|
||||||
|
releaseExclusiveTask()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer releaseExclusiveTask()
|
||||||
|
runRegenerateAssetsJob(job)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -206,7 +206,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
RegenerateAssets: regenerate,
|
RegenerateAssets: regenerate,
|
||||||
RegenerateAssetsJobs: regenerateJobs,
|
RegenerateAssetsJobs: regenerateJobs,
|
||||||
Cleanup: cleanup,
|
Cleanup: cleanup,
|
||||||
AnyRunning: assets.Running || anyRegenerateRunning || cleanup.Running,
|
AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import ModelDetails from './components/ui/ModelDetails'
|
|||||||
import {
|
import {
|
||||||
SignalIcon,
|
SignalIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
HandThumbUpIcon,
|
StarIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
ArchiveBoxIcon,
|
ArchiveBoxIcon,
|
||||||
@ -149,6 +149,7 @@ type StoredModel = {
|
|||||||
input: string
|
input: string
|
||||||
host?: string
|
host?: string
|
||||||
modelKey: string
|
modelKey: string
|
||||||
|
|
||||||
watching: boolean
|
watching: boolean
|
||||||
favorite?: boolean
|
favorite?: boolean
|
||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
@ -159,6 +160,11 @@ type StoredModel = {
|
|||||||
isOnline?: boolean
|
isOnline?: boolean
|
||||||
chatRoomUrl?: string
|
chatRoomUrl?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
|
|
||||||
lastOnlineAt?: string
|
lastOnlineAt?: string
|
||||||
lastOfflineAt?: string
|
lastOfflineAt?: string
|
||||||
lastRoomSyncAt?: string
|
lastRoomSyncAt?: string
|
||||||
@ -2318,12 +2324,16 @@ export default function App() {
|
|||||||
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot])
|
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot])
|
||||||
|
|
||||||
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
|
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
|
||||||
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
|
const startUrl = useCallback(async (
|
||||||
|
rawUrl: string,
|
||||||
|
opts?: { silent?: boolean; immediate?: boolean }
|
||||||
|
): Promise<boolean> => {
|
||||||
const norm0 = normalizeHttpUrl(rawUrl)
|
const norm0 = normalizeHttpUrl(rawUrl)
|
||||||
if (!norm0) return false
|
if (!norm0) return false
|
||||||
|
|
||||||
const norm = canonicalizeProviderUrl(norm0)
|
const norm = canonicalizeProviderUrl(norm0)
|
||||||
const silent = Boolean(opts?.silent)
|
const silent = Boolean(opts?.silent)
|
||||||
|
const immediate = Boolean(opts?.immediate)
|
||||||
|
|
||||||
if (!silent) setError(null)
|
if (!silent) setError(null)
|
||||||
|
|
||||||
@ -2360,20 +2370,24 @@ export default function App() {
|
|||||||
|
|
||||||
const keyLower = providerKeyLowerFromUrl(norm)
|
const keyLower = providerKeyLowerFromUrl(norm)
|
||||||
|
|
||||||
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
|
// Nur automatische Starts in pending-autostart schieben.
|
||||||
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
// Manuelle UI-Starts müssen sofort direkt starten.
|
||||||
mode: 'probe_retry',
|
if (!immediate) {
|
||||||
source: 'manual',
|
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
|
||||||
})
|
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
||||||
return true
|
mode: 'probe_retry',
|
||||||
}
|
source: 'manual',
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
|
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
|
||||||
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
||||||
mode: 'probe_retry',
|
mode: 'probe_retry',
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -2926,7 +2940,7 @@ export default function App() {
|
|||||||
}, [playerJob, modelsByKey])
|
}, [playerJob, modelsByKey])
|
||||||
|
|
||||||
async function onStart() {
|
async function onStart() {
|
||||||
const ok = await startUrl(sourceUrl, { silent: false })
|
const ok = await startUrl(sourceUrl, { silent: false, immediate: true })
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setSourceUrl('')
|
setSourceUrl('')
|
||||||
@ -2958,7 +2972,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = canonicalizeProviderUrl(norm0)
|
const url = canonicalizeProviderUrl(norm0)
|
||||||
const ok = await startUrl(url, { silent: true })
|
const ok = await startUrl(url, { silent: true, immediate: true })
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
|
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
|
||||||
@ -3618,12 +3632,12 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={headerStatBadgeClass} title="Fav online">
|
<span className={headerStatBadgeClass} title="Fav online">
|
||||||
<HeartIcon className="size-4 opacity-80" />
|
<StarIcon className="size-4 opacity-80" />
|
||||||
<span className="tabular-nums">{onlineFavCount}</span>
|
<span className="tabular-nums">{onlineFavCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={headerStatBadgeClass} title="Like online">
|
<span className={headerStatBadgeClass} title="Like online">
|
||||||
<HandThumbUpIcon className="size-4 opacity-80" />
|
<HeartIcon className="size-4 opacity-80" />
|
||||||
<span className="tabular-nums">{onlineLikedCount}</span>
|
<span className="tabular-nums">{onlineLikedCount}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -3661,12 +3675,12 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={headerStatBadgeClass} title="Fav online">
|
<span className={headerStatBadgeClass} title="Fav online">
|
||||||
<HeartIcon className="size-4 opacity-80" />
|
<StarIcon className="size-4 opacity-80" />
|
||||||
<span className="tabular-nums">{onlineFavCount}</span>
|
<span className="tabular-nums">{onlineFavCount}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className={headerStatBadgeClass} title="Like online">
|
<span className={headerStatBadgeClass} title="Like online">
|
||||||
<HandThumbUpIcon className="size-4 opacity-80" />
|
<HeartIcon className="size-4 opacity-80" />
|
||||||
<span className="tabular-nums">{onlineLikedCount}</span>
|
<span className="tabular-nums">{onlineLikedCount}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -26,7 +26,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
'inline-flex items-center justify-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
|
||||||
const roundedMap = {
|
const roundedMap = {
|
||||||
sm: 'rounded-sm',
|
sm: 'rounded-sm',
|
||||||
@ -47,11 +47,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
|||||||
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
|
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
|
||||||
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
|
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'bg-white text-gray-900 hover:bg-gray-100 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
|
||||||
soft:
|
soft:
|
||||||
'bg-indigo-50 text-indigo-600 shadow-xs hover:bg-indigo-100 ' +
|
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' +
|
||||||
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:hover:bg-indigo-500/30',
|
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30',
|
||||||
},
|
},
|
||||||
|
|
||||||
blue: {
|
blue: {
|
||||||
@ -59,11 +59,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
|||||||
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
|
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
|
||||||
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
|
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'bg-white text-gray-900 hover:bg-gray-100 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
|
||||||
soft:
|
soft:
|
||||||
'bg-blue-50 text-blue-600 shadow-xs hover:bg-blue-100 ' +
|
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' +
|
||||||
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:hover:bg-blue-500/30',
|
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30',
|
||||||
},
|
},
|
||||||
|
|
||||||
emerald: {
|
emerald: {
|
||||||
@ -71,11 +71,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
|||||||
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
|
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
|
||||||
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
|
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'bg-white text-gray-900 hover:bg-gray-100 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
|
||||||
soft:
|
soft:
|
||||||
'bg-emerald-50 text-emerald-700 shadow-xs hover:bg-emerald-100 ' +
|
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' +
|
||||||
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:hover:bg-emerald-500/30',
|
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30',
|
||||||
},
|
},
|
||||||
|
|
||||||
red: {
|
red: {
|
||||||
@ -83,11 +83,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
|||||||
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
|
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
|
||||||
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
|
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'bg-white text-gray-900 hover:bg-gray-100 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
|
||||||
soft:
|
soft:
|
||||||
'bg-red-50 text-red-700 shadow-xs hover:bg-red-100 ' +
|
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' +
|
||||||
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:hover:bg-red-500/30',
|
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30',
|
||||||
},
|
},
|
||||||
|
|
||||||
amber: {
|
amber: {
|
||||||
@ -95,11 +95,11 @@ const colorMap: Record<Color, Record<Variant, string>> = {
|
|||||||
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
|
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
|
||||||
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
|
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs inset-ring inset-ring-gray-300 hover:bg-gray-50 ' +
|
'bg-white text-gray-900 hover:bg-gray-100 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:hover:bg-white/20',
|
||||||
soft:
|
soft:
|
||||||
'bg-amber-50 text-amber-800 shadow-xs hover:bg-amber-100 ' +
|
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' +
|
||||||
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:hover:bg-amber-500/30',
|
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,9 @@ type Size = 'sm' | 'md' | 'lg'
|
|||||||
|
|
||||||
export type ButtonGroupItem = {
|
export type ButtonGroupItem = {
|
||||||
id: string
|
id: string
|
||||||
label?: React.ReactNode // optional (für icon-only)
|
label?: React.ReactNode
|
||||||
icon?: React.ReactNode
|
icon?: React.ReactNode
|
||||||
srLabel?: string // für icon-only (Screenreader)
|
srLabel?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ export default function ButtonGroup({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'isolate inline-flex rounded-md shadow-xs dark:shadow-none ring-1 ring-gray-300 dark:ring-gray-700 overflow-hidden',
|
'isolate inline-flex overflow-hidden rounded-md shadow-xs ring-1 ring-gray-300 dark:shadow-none dark:ring-gray-700',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role="group"
|
||||||
@ -65,20 +65,28 @@ export default function ButtonGroup({
|
|||||||
onClick={() => onChange(it.id)}
|
onClick={() => onChange(it.id)}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative inline-flex items-center justify-center font-semibold leading-none focus:z-10 transition-colors',
|
'relative inline-flex items-center justify-center font-semibold leading-none',
|
||||||
!isFirst && 'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
|
'transition-[background-color,color,box-shadow] duration-150',
|
||||||
|
'focus:z-20 hover:z-10',
|
||||||
|
!isFirst && 'before:absolute before:bottom-0 before:left-0 before:top-0 before:w-px before:bg-gray-300 dark:before:bg-gray-700',
|
||||||
isFirst && 'rounded-l-md',
|
isFirst && 'rounded-l-md',
|
||||||
isLast && 'rounded-r-md',
|
isLast && 'rounded-r-md',
|
||||||
|
|
||||||
// Base vs Active: gegenseitig ausschließen (wichtig, sonst gewinnt oft bg-white)
|
|
||||||
active
|
active
|
||||||
? 'bg-indigo-100 text-indigo-800 hover:bg-indigo-200 dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50'
|
? [
|
||||||
: 'bg-white text-gray-900 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:hover:bg-white/20',
|
'bg-indigo-100 text-indigo-800',
|
||||||
|
'hover:bg-indigo-200',
|
||||||
|
'hover:shadow-[inset_0_0_0_1px_rgba(99,102,241,0.28),inset_0_0_0_9999px_rgba(99,102,241,0.10)]',
|
||||||
|
'dark:bg-indigo-500/40 dark:text-indigo-100 dark:hover:bg-indigo-500/50 dark:hover:shadow-none',
|
||||||
|
].join(' ')
|
||||||
|
: [
|
||||||
|
'bg-white text-gray-900',
|
||||||
|
'hover:bg-gray-50',
|
||||||
|
'hover:shadow-[inset_0_0_0_1px_rgba(148,163,184,0.35),inset_0_0_0_9999px_rgba(15,23,42,0.05)]',
|
||||||
|
'dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:hover:shadow-none',
|
||||||
|
].join(' '),
|
||||||
|
|
||||||
// Disabled
|
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
|
|
||||||
// Padding / Größe
|
|
||||||
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
iconOnly ? `p-0 ${s.iconOnly}` : s.btn
|
||||||
)}
|
)}
|
||||||
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
title={typeof it.label === 'string' ? it.label : it.srLabel}
|
||||||
@ -88,9 +96,11 @@ export default function ButtonGroup({
|
|||||||
{it.icon ? (
|
{it.icon ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'shrink-0',
|
'shrink-0 transition-colors duration-150',
|
||||||
iconOnly ? '' : '-ml-0.5',
|
iconOnly ? '' : '-ml-0.5',
|
||||||
active ? 'text-indigo-600 dark:text-indigo-200' : 'text-gray-400 dark:text-gray-500'
|
active
|
||||||
|
? 'text-indigo-700 dark:text-indigo-200'
|
||||||
|
: 'text-gray-500 dark:text-gray-500'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{it.icon}
|
{it.icon}
|
||||||
|
|||||||
@ -7,11 +7,13 @@ import Table, { type Column } from './Table'
|
|||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import ModelPreview from './ModelPreview'
|
import ModelPreview from './ModelPreview'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import ProgressBar from './ProgressBar'
|
import ProgressBar from './ProgressBar'
|
||||||
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
|
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
|
||||||
import { useMediaQuery } from '../../lib/useMediaQuery'
|
import { useMediaQuery } from '../../lib/useMediaQuery'
|
||||||
import DownloadsCardRow, {
|
import DownloadsCardRow, {
|
||||||
|
PendingPreviewImage,
|
||||||
type DownloadRow,
|
type DownloadRow,
|
||||||
type PendingWatchedRoom,
|
type PendingWatchedRoom,
|
||||||
} from './DownloadsCardRow'
|
} from './DownloadsCardRow'
|
||||||
@ -38,6 +40,9 @@ type Props = {
|
|||||||
watching?: boolean
|
watching?: boolean
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
roomStatusByModelKey?: Record<string, string>
|
roomStatusByModelKey?: Record<string, string>
|
||||||
@ -56,6 +61,31 @@ type Props = {
|
|||||||
maxConcurrentDownloads?: number
|
maxConcurrentDownloads?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isChaturbateJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||||
|
): boolean => {
|
||||||
|
const anyJ = job as any
|
||||||
|
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
|
||||||
|
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
|
||||||
|
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
src.includes('chaturbate.com') ||
|
||||||
|
chat.includes('chaturbate.com') ||
|
||||||
|
img.includes('highwebmedia') ||
|
||||||
|
img.includes('chaturbate')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewInitialSrcOfJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||||
|
): string => {
|
||||||
|
if (!isChaturbateJob(job, modelsByKey)) return ''
|
||||||
|
return onlineImageUrlOfJob(job, modelsByKey)
|
||||||
|
}
|
||||||
|
|
||||||
const pendingModelName = (p: PendingWatchedRoom) => {
|
const pendingModelName = (p: PendingWatchedRoom) => {
|
||||||
const anyP = p as any
|
const anyP = p as any
|
||||||
return (
|
return (
|
||||||
@ -68,13 +98,6 @@ const pendingModelName = (p: PendingWatchedRoom) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRefreshPreview = (j: RecordJob) => {
|
|
||||||
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
|
||||||
const status = String(j.status ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingUrl = (p: PendingWatchedRoom) => {
|
const pendingUrl = (p: PendingWatchedRoom) => {
|
||||||
const anyP = p as any
|
const anyP = p as any
|
||||||
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
|
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
|
||||||
@ -588,8 +611,6 @@ export default function Downloads({
|
|||||||
|
|
||||||
const [watchedBusy, setWatchedBusy] = useState(false)
|
const [watchedBusy, setWatchedBusy] = useState(false)
|
||||||
|
|
||||||
const [thumbTick, setThumbTick] = useState(0)
|
|
||||||
|
|
||||||
const refreshWatchedState = useCallback(async () => {
|
const refreshWatchedState = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await onRefreshAutostartState?.()
|
await onRefreshAutostartState?.()
|
||||||
@ -598,33 +619,6 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
}, [onRefreshAutostartState])
|
}, [onRefreshAutostartState])
|
||||||
|
|
||||||
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onVis = () => setPageVisible(!document.hidden)
|
|
||||||
document.addEventListener('visibilitychange', onVis)
|
|
||||||
return () => document.removeEventListener('visibilitychange', onVis)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const activePreviewJobs = jobs.filter((j) => {
|
|
||||||
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
|
||||||
const status = String(j.status ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
|
|
||||||
}).length
|
|
||||||
|
|
||||||
if (activePreviewJobs === 0 || !pageVisible) return
|
|
||||||
|
|
||||||
const intervalMs = activePreviewJobs > 2 ? 30000 : 10000
|
|
||||||
|
|
||||||
const id = window.setInterval(() => {
|
|
||||||
setThumbTick((t) => t + 1)
|
|
||||||
}, intervalMs)
|
|
||||||
|
|
||||||
return () => window.clearInterval(id)
|
|
||||||
}, [jobs, pageVisible])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPaused = Boolean(autostartState?.paused)
|
const nextPaused = Boolean(autostartState?.paused)
|
||||||
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
||||||
@ -971,8 +965,7 @@ export default function Downloads({
|
|||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
if (r.kind === 'job') {
|
if (r.kind === 'job') {
|
||||||
const j = r.job
|
const j = r.job
|
||||||
const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
|
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
|
||||||
const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
|
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
|
||||||
@ -982,8 +975,7 @@ export default function Downloads({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
initialSrc={onlineImageUrl || providerFallbackSrc}
|
initialSrc={directPreviewSrc || undefined}
|
||||||
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
|
|
||||||
enableHoverLive={true}
|
enableHoverLive={true}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={effectiveRoomStatusOfJob(
|
roomStatus={effectiveRoomStatusOfJob(
|
||||||
@ -992,7 +984,7 @@ export default function Downloads({
|
|||||||
modelsByKey,
|
modelsByKey,
|
||||||
growingByJobId
|
growingByJobId
|
||||||
)}
|
)}
|
||||||
fallbackSrc={providerFallbackSrc}
|
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</LazyMountWhenVisible>
|
</LazyMountWhenVisible>
|
||||||
@ -1007,18 +999,11 @@ export default function Downloads({
|
|||||||
if (img) {
|
if (img) {
|
||||||
return (
|
return (
|
||||||
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
|
||||||
<img
|
<PendingPreviewImage
|
||||||
src={img}
|
src={img}
|
||||||
alt={name}
|
alt={name}
|
||||||
className={[
|
blur={blurPreviews}
|
||||||
'h-full w-full object-cover',
|
className="h-full w-full"
|
||||||
blurPreviews ? 'blur-md' : '',
|
|
||||||
].join(' ')}
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={(e) => {
|
|
||||||
;(e.currentTarget as HTMLImageElement).style.display = 'none'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -1040,6 +1025,8 @@ export default function Downloads({
|
|||||||
const j = r.job
|
const j = r.job
|
||||||
const f = baseName(j.output || '')
|
const f = baseName(j.output || '')
|
||||||
const name = modelNameFromOutput(j.output)
|
const name = modelNameFromOutput(j.output)
|
||||||
|
const modelKey = modelKeyFromJob(j)
|
||||||
|
const modelMeta = modelKey ? modelsByKey[modelKey] : undefined
|
||||||
|
|
||||||
const roomStatus = effectiveRoomStatusOfJob(
|
const roomStatus = effectiveRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
@ -1054,6 +1041,14 @@ export default function Downloads({
|
|||||||
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
|
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={modelMeta?.gender}
|
||||||
|
sex={modelMeta?.sex}
|
||||||
|
subgender={modelMeta?.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
||||||
@ -1091,11 +1086,24 @@ export default function Downloads({
|
|||||||
const name = pendingModelName(p)
|
const name = pendingModelName(p)
|
||||||
const url = pendingUrl(p)
|
const url = pendingUrl(p)
|
||||||
|
|
||||||
|
const pendingKey = String((p as any)?.modelKey ?? name).trim().toLowerCase()
|
||||||
|
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="block max-w-[170px] truncate font-medium" title={name}>
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{name}
|
<span className="block max-w-[170px] truncate font-medium" title={name}>
|
||||||
</span>
|
{name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={pendingFlags?.gender}
|
||||||
|
sex={pendingFlags?.sex}
|
||||||
|
subgender={pendingFlags?.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{url ? (
|
{url ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
@ -1177,11 +1185,48 @@ export default function Downloads({
|
|||||||
widthClassName: 'w-[320px] min-w-[300px]',
|
widthClassName: 'w-[320px] min-w-[300px]',
|
||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
if (r.kind !== 'job') {
|
if (r.kind !== 'job') {
|
||||||
const pendingKey = pendingRowKey(r.pending)
|
const pendingKey = String(
|
||||||
const isRemovingPending = Boolean(removePendingRequestedKeys[pendingKey])
|
(r.pending as any)?.modelKey ??
|
||||||
|
pendingModelName(r.pending)
|
||||||
|
).trim().toLowerCase()
|
||||||
|
|
||||||
|
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
|
||||||
|
|
||||||
|
const pendingActionJob = {
|
||||||
|
id: `pending:${pendingKey || pendingRowKey(r.pending)}`,
|
||||||
|
output: `${pendingModelName(r.pending)}_pending.mp4`,
|
||||||
|
sourceUrl: pendingUrl(r.pending),
|
||||||
|
status: 'pending',
|
||||||
|
endedAt: null,
|
||||||
|
} as unknown as RecordJob
|
||||||
|
|
||||||
|
const isFav = Boolean(pendingFlags?.favorite)
|
||||||
|
const isLiked = pendingFlags?.liked === true
|
||||||
|
const isWatching = Boolean(pendingFlags?.watching)
|
||||||
|
|
||||||
|
const removeKey = pendingRowKey(r.pending)
|
||||||
|
const isRemovingPending = Boolean(removePendingRequestedKeys[removeKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end pr-2">
|
<div
|
||||||
|
className="flex items-center justify-end gap-2 pr-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<RecordJobActions
|
||||||
|
job={pendingActionJob}
|
||||||
|
variant="table"
|
||||||
|
busy={false}
|
||||||
|
isFavorite={isFav}
|
||||||
|
isLiked={isLiked}
|
||||||
|
isWatching={isWatching}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleLike={onToggleLike}
|
||||||
|
onToggleWatch={onToggleWatch}
|
||||||
|
order={['watch', 'favorite', 'like', 'details']}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@ -1192,11 +1237,11 @@ export default function Downloads({
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (isRemovingPending) return
|
if (isRemovingPending) return
|
||||||
|
|
||||||
markRemovePendingRequested(pendingKey)
|
markRemovePendingRequested(removeKey)
|
||||||
try {
|
try {
|
||||||
await onRemovePending?.(r.pending)
|
await onRemovePending?.(r.pending)
|
||||||
} catch {
|
} catch {
|
||||||
clearRemovePendingRequested(pendingKey)
|
clearRemovePendingRequested(removeKey)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -1634,7 +1679,6 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
thumbTick={thumbTick}
|
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
@ -1692,7 +1736,6 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
thumbTick={thumbTick}
|
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
@ -1774,7 +1817,6 @@ export default function Downloads({
|
|||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
thumbTick={thumbTick}
|
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
modelsByKey={modelsByKey}
|
modelsByKey={modelsByKey}
|
||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
|
|||||||
@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState, useRef, useEffect } from 'react'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import ModelPreview from './ModelPreview'
|
import ModelPreview from './ModelPreview'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
import ProgressBar from './ProgressBar'
|
import ProgressBar from './ProgressBar'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
@ -31,12 +33,14 @@ type ModelFlags = {
|
|||||||
watching?: boolean
|
watching?: boolean
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
r: DownloadRow
|
r: DownloadRow
|
||||||
nowMs: number
|
nowMs: number
|
||||||
thumbTick?: number
|
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
modelsByKey: Record<string, ModelFlags>
|
modelsByKey: Record<string, ModelFlags>
|
||||||
roomStatusByModelKey: Record<string, string>
|
roomStatusByModelKey: Record<string, string>
|
||||||
@ -126,6 +130,31 @@ const modelKeyFromJob = (job: RecordJob): string => {
|
|||||||
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
|
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isChaturbateJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||||
|
): boolean => {
|
||||||
|
const anyJ = job as any
|
||||||
|
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
|
||||||
|
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
|
||||||
|
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
src.includes('chaturbate.com') ||
|
||||||
|
chat.includes('chaturbate.com') ||
|
||||||
|
img.includes('highwebmedia') ||
|
||||||
|
img.includes('chaturbate')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewInitialSrcOfJob = (
|
||||||
|
job: RecordJob,
|
||||||
|
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||||
|
): string => {
|
||||||
|
if (!isChaturbateJob(job, modelsByKey)) return ''
|
||||||
|
return onlineImageUrlOfJob(job, modelsByKey)
|
||||||
|
}
|
||||||
|
|
||||||
const onlineImageUrlOfJob = (
|
const onlineImageUrlOfJob = (
|
||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
modelsByKey?: Record<string, { imageUrl?: string }>
|
modelsByKey?: Record<string, { imageUrl?: string }>
|
||||||
@ -441,10 +470,96 @@ function postWorkLabel(
|
|||||||
return 'Warte auf Nacharbeiten…'
|
return 'Warte auf Nacharbeiten…'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PendingPreviewImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
blur,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
src: string
|
||||||
|
alt: string
|
||||||
|
blur?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [inView, setInView] = useState(false)
|
||||||
|
const [nonce, setNonce] = useState(0)
|
||||||
|
const [imgError, setImgError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
|
||||||
|
},
|
||||||
|
{ root: null, threshold: 0.01 }
|
||||||
|
)
|
||||||
|
|
||||||
|
obs.observe(el)
|
||||||
|
return () => obs.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) return
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setNonce((n) => n + 1)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [inView, src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgError(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
const finalSrc = useMemo(() => {
|
||||||
|
if (!inView) return ''
|
||||||
|
|
||||||
|
const s = String(src ?? '').trim()
|
||||||
|
if (!s) return ''
|
||||||
|
|
||||||
|
return s.includes('?') ? `${s}&v=${nonce}` : `${s}?v=${nonce}`
|
||||||
|
}, [src, nonce, inView])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{inView && !imgError && finalSrc ? (
|
||||||
|
<img
|
||||||
|
src={finalSrc}
|
||||||
|
alt={alt}
|
||||||
|
className={[
|
||||||
|
'block h-full w-full object-cover',
|
||||||
|
blur ? 'blur-md' : '',
|
||||||
|
].join(' ')}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'grid h-full w-full place-items-center bg-gray-100 text-sm text-gray-500 dark:bg-white/10 dark:text-gray-300',
|
||||||
|
blur ? 'blur-md' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
⏳
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DownloadsCardRow({
|
export default function DownloadsCardRow({
|
||||||
r,
|
r,
|
||||||
nowMs,
|
nowMs,
|
||||||
thumbTick,
|
|
||||||
blurPreviews,
|
blurPreviews,
|
||||||
modelsByKey,
|
modelsByKey,
|
||||||
roomStatusByModelKey,
|
roomStatusByModelKey,
|
||||||
@ -464,7 +579,25 @@ export default function DownloadsCardRow({
|
|||||||
const name = pendingModelName(p)
|
const name = pendingModelName(p)
|
||||||
const url = pendingUrl(p)
|
const url = pendingUrl(p)
|
||||||
const show = normalizeRoomStatus(p.currentShow)
|
const show = normalizeRoomStatus(p.currentShow)
|
||||||
const img = pendingImageUrl(p)
|
|
||||||
|
const pendingKey = String(p.modelKey ?? name).trim().toLowerCase()
|
||||||
|
const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined
|
||||||
|
|
||||||
|
const img =
|
||||||
|
pendingImageUrl(p) ||
|
||||||
|
String(pendingFlags?.imageUrl ?? '').trim()
|
||||||
|
|
||||||
|
const pendingActionJob = {
|
||||||
|
id: `pending:${pendingKey || name}`,
|
||||||
|
output: `${name}_pending.mp4`,
|
||||||
|
sourceUrl: url,
|
||||||
|
status: 'pending',
|
||||||
|
endedAt: null,
|
||||||
|
} as unknown as RecordJob
|
||||||
|
|
||||||
|
const isFav = Boolean(pendingFlags?.favorite)
|
||||||
|
const isLiked = pendingFlags?.liked === true
|
||||||
|
const isWatching = Boolean(pendingFlags?.watching)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -483,26 +616,18 @@ export default function DownloadsCardRow({
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
relative
|
relative shrink-0 overflow-hidden rounded-lg
|
||||||
shrink-0 overflow-hidden rounded-lg
|
|
||||||
w-[112px] h-[64px]
|
w-[112px] h-[64px]
|
||||||
bg-gray-100 ring-1 ring-black/5
|
bg-gray-100 ring-1 ring-black/5
|
||||||
dark:bg-white/10 dark:ring-white/10
|
dark:bg-white/10 dark:ring-white/10
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{img ? (
|
{img ? (
|
||||||
<img
|
<PendingPreviewImage
|
||||||
src={img}
|
src={img}
|
||||||
alt={name}
|
alt={name}
|
||||||
className={[
|
blur={blurPreviews}
|
||||||
'h-full w-full object-cover',
|
className="h-full w-full"
|
||||||
blurPreviews ? 'blur-md' : '',
|
|
||||||
].join(' ')}
|
|
||||||
loading="lazy"
|
|
||||||
referrerPolicy="no-referrer"
|
|
||||||
onError={(e) => {
|
|
||||||
;(e.currentTarget as HTMLImageElement).style.display = 'none'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
|
<div className="grid h-full w-full place-items-center text-sm text-gray-500 dark:text-gray-300">
|
||||||
@ -512,35 +637,38 @@ export default function DownloadsCardRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="truncate text-base font-semibold text-gray-900 dark:text-white"
|
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
|
||||||
title={name}
|
title={name}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<ModelGenderIcon
|
||||||
className={[
|
gender={pendingFlags?.gender}
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
sex={pendingFlags?.sex}
|
||||||
roomStatusTone(show),
|
subgender={pendingFlags?.subgender}
|
||||||
].join(' ')}
|
className="shrink-0"
|
||||||
title={show}
|
/>
|
||||||
>
|
|
||||||
{show}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
|
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
|
||||||
Wartend auf Autostart…
|
Warte auf Online-Status…
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
<div className="shrink-0 flex items-start">
|
||||||
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
<span
|
||||||
⏳
|
className={[
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
||||||
|
roomStatusTone(show),
|
||||||
|
].join(' ')}
|
||||||
|
title={show}
|
||||||
|
>
|
||||||
|
{show}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -560,32 +688,47 @@ export default function DownloadsCardRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
|
||||||
<ProgressBar
|
<div
|
||||||
label="Warte auf Online-Status…"
|
className="flex items-center gap-1"
|
||||||
indeterminate
|
onClick={(e) => e.stopPropagation()}
|
||||||
size="sm"
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className="w-full"
|
>
|
||||||
/>
|
<RecordJobActions
|
||||||
</div>
|
job={pendingActionJob}
|
||||||
|
variant="table"
|
||||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
|
busy={false}
|
||||||
<div className="flex items-center gap-1 opacity-60">
|
isFavorite={isFav}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
isLiked={isLiked}
|
||||||
Wird automatisch gestartet, sobald das Model public ist.
|
isWatching={isWatching}
|
||||||
</span>
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
onToggleLike={onToggleLike}
|
||||||
|
onToggleWatch={onToggleWatch}
|
||||||
|
order={['watch', 'favorite', 'like', 'details']}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
<div
|
||||||
<Button
|
className="ml-auto flex items-center justify-end gap-2"
|
||||||
size="sm"
|
onClick={(e) => e.stopPropagation()}
|
||||||
variant="primary"
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
color="red"
|
>
|
||||||
className="shrink-0"
|
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
|
||||||
disabled
|
|
||||||
>
|
|
||||||
Wartend
|
Wartend
|
||||||
</Button>
|
</span>
|
||||||
|
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
color="red"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Wartend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -654,18 +797,12 @@ export default function DownloadsCardRow({
|
|||||||
Boolean(phase) &&
|
Boolean(phase) &&
|
||||||
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
||||||
|
|
||||||
const key = name && name !== '—' ? name.toLowerCase() : ''
|
const modelKey = modelKeyFromJob(j)
|
||||||
const flags = key ? modelsByKey[key] : undefined
|
const flags = modelKey ? modelsByKey[modelKey] : undefined
|
||||||
const isFav = Boolean(flags?.favorite)
|
const isFav = Boolean(flags?.favorite)
|
||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
const isWatching = Boolean(flags?.watching)
|
||||||
|
const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey)
|
||||||
const shouldRefreshPreview = (j: RecordJob) => {
|
|
||||||
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
|
||||||
const status = String(j.status ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -703,11 +840,7 @@ export default function DownloadsCardRow({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
initialSrc={
|
initialSrc={directPreviewSrc || undefined}
|
||||||
onlineImageUrlOfJob(j, modelsByKey) ||
|
|
||||||
`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
|
|
||||||
}
|
|
||||||
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
|
|
||||||
enableHoverLive={false}
|
enableHoverLive={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={previewRoomStatusOfJob(
|
roomStatus={previewRoomStatusOfJob(
|
||||||
@ -723,33 +856,41 @@ export default function DownloadsCardRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
|
<div
|
||||||
|
className="min-w-0 truncate text-base font-semibold text-gray-900 dark:text-white"
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className={[
|
<ModelGenderIcon
|
||||||
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
gender={flags?.gender}
|
||||||
roomStatusTone(roomStatus),
|
sex={flags?.sex}
|
||||||
].join(' ')}
|
subgender={flags?.subgender}
|
||||||
title={roomStatus}
|
className="shrink-0"
|
||||||
>
|
/>
|
||||||
{roomStatus}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
|
|
||||||
|
<div
|
||||||
|
className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300"
|
||||||
|
title={j.output}
|
||||||
|
>
|
||||||
{file || '—'}
|
{file || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
<div className="shrink-0 flex items-start">
|
||||||
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
<span
|
||||||
{runtimeOf(j, nowMs)}
|
className={[
|
||||||
</span>
|
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
|
||||||
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
|
roomStatusTone(roomStatus),
|
||||||
{formatBytes(sizeBytesOf(j))}
|
].join(' ')}
|
||||||
|
title={roomStatus}
|
||||||
|
>
|
||||||
|
{roomStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -790,7 +931,7 @@ export default function DownloadsCardRow({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-white/30 pt-3 dark:border-white/10">
|
<div className="mt-3 flex items-center gap-3 border-t border-white/30 pt-3 dark:border-white/10">
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -811,28 +952,43 @@ export default function DownloadsCardRow({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
<div
|
||||||
<Button
|
className="ml-auto flex min-w-0 self-center items-center justify-end gap-2"
|
||||||
size="sm"
|
onClick={(e) => e.stopPropagation()}
|
||||||
variant="primary"
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
color={isQueuedPostwork ? 'red' : 'indigo'}
|
>
|
||||||
disabled={disableStopButton}
|
<div className="flex items-center justify-end gap-2 text-right">
|
||||||
className="shrink-0"
|
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
|
||||||
onClick={async (e) => {
|
{runtimeOf(j, nowMs)}
|
||||||
e.stopPropagation()
|
</span>
|
||||||
|
<span className="rounded-full bg-gray-900/5 px-2 py-0.5 text-[11px] font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200 whitespace-nowrap">
|
||||||
|
{formatBytes(sizeBytesOf(j))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
if (showRemoveQueuedButton) {
|
<div className="hidden sm:block">
|
||||||
await onRemoveQueuedPostworkJob?.(j.id)
|
<Button
|
||||||
return
|
size="sm"
|
||||||
}
|
variant="primary"
|
||||||
|
color={isQueuedPostwork ? 'red' : 'indigo'}
|
||||||
|
disabled={disableStopButton}
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
if (isStopping) return
|
if (showRemoveQueuedButton) {
|
||||||
markStopRequested(j.id)
|
await onRemoveQueuedPostworkJob?.(j.id)
|
||||||
await onStopJob(j.id)
|
return
|
||||||
}}
|
}
|
||||||
>
|
|
||||||
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
if (isStopping) return
|
||||||
</Button>
|
markStopRequested(j.id)
|
||||||
|
await onStopJob(j.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -29,6 +29,7 @@ import {
|
|||||||
readPreviewSpriteInfo,
|
readPreviewSpriteInfo,
|
||||||
scrubProgressRatioFromIndex,
|
scrubProgressRatioFromIndex,
|
||||||
} from './previewSprite'
|
} from './previewSprite'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
@ -536,13 +537,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
|
const previewRenderRole = opts?.renderRole ?? (isSmall ? 'top' : 'desktop')
|
||||||
|
|
||||||
const previewInstanceKey = [
|
|
||||||
k,
|
|
||||||
previewRenderRole,
|
|
||||||
inlineActive ? 'inline' : 'teaser',
|
|
||||||
allowTeaserAnimation ? 'anim' : 'still',
|
|
||||||
].join('::')
|
|
||||||
|
|
||||||
const showMobileAudioButton =
|
const showMobileAudioButton =
|
||||||
Boolean(onUnlockMobileTeaserAudio) &&
|
Boolean(onUnlockMobileTeaserAudio) &&
|
||||||
isMobileTopCard &&
|
isMobileTopCard &&
|
||||||
@ -700,7 +694,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
key={previewInstanceKey}
|
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
@ -859,8 +852,17 @@ export default function FinishedDownloadsCardsView({
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2 min-w-0">
|
<div className="flex items-start justify-between gap-2 min-w-0">
|
||||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="min-w-0 flex items-center gap-1.5">
|
||||||
{model}
|
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={flags?.gender}
|
||||||
|
sex={flags?.sex}
|
||||||
|
subgender={flags?.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderPostworkBadge && livePostwork ? (
|
{renderPostworkBadge && livePostwork ? (
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
readPreviewSpriteInfo,
|
readPreviewSpriteInfo,
|
||||||
scrubProgressRatioFromIndex,
|
scrubProgressRatioFromIndex,
|
||||||
} from './previewSprite'
|
} from './previewSprite'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
|
|
||||||
function normalizeDurationSeconds(value: unknown): number | undefined {
|
function normalizeDurationSeconds(value: unknown): number | undefined {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined
|
||||||
@ -48,7 +49,7 @@ type Props = {
|
|||||||
) => FinishedPostworkSummary | undefined
|
) => FinishedPostworkSummary | undefined
|
||||||
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode
|
||||||
|
|
||||||
durations: Record<string, number>
|
durationSeconds?: number
|
||||||
teaserState: FinishedDownloadsTeaserState
|
teaserState: FinishedDownloadsTeaserState
|
||||||
|
|
||||||
keyFor: (j: RecordJob) => string
|
keyFor: (j: RecordJob) => string
|
||||||
@ -100,7 +101,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
postworkByFile,
|
postworkByFile,
|
||||||
getPostworkSummaryForOutput,
|
getPostworkSummaryForOutput,
|
||||||
renderPostworkBadge,
|
renderPostworkBadge,
|
||||||
durations,
|
durationSeconds,
|
||||||
teaserState,
|
teaserState,
|
||||||
keyFor,
|
keyFor,
|
||||||
baseName,
|
baseName,
|
||||||
@ -261,7 +262,7 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
const hideTeaserUnderOverlay = showModelPreviewInThumb || showScrubberSpriteInThumb
|
||||||
|
|
||||||
const previewDurationSeconds =
|
const previewDurationSeconds =
|
||||||
durations[k] ??
|
durationSeconds ??
|
||||||
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
normalizeDurationSeconds(meta?.media?.durationSeconds) ??
|
||||||
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
normalizeDurationSeconds(meta?.file?.durationSeconds)
|
||||||
|
|
||||||
@ -336,7 +337,6 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
key={`gallery-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
|
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
durationSeconds={previewDurationSeconds}
|
durationSeconds={previewDurationSeconds}
|
||||||
@ -442,8 +442,17 @@ function FinishedDownloadsGalleryCardInner({
|
|||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
|
<div className="mt-0.5 flex min-h-[22px] items-start justify-between gap-2 min-w-0">
|
||||||
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
|
<div className="min-w-0 flex items-center gap-1.5">
|
||||||
{model}
|
<div className={['min-w-0 truncate font-semibold text-gray-900 dark:text-white', titleClass].join(' ')}>
|
||||||
|
{model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={flags?.gender}
|
||||||
|
sex={flags?.sex}
|
||||||
|
subgender={flags?.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPostworkBadge && renderPostworkBadge ? (
|
{showPostworkBadge && renderPostworkBadge ? (
|
||||||
|
|||||||
@ -219,6 +219,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
>
|
>
|
||||||
{rows.map((j) => {
|
{rows.map((j) => {
|
||||||
const k = keyFor(j)
|
const k = keyFor(j)
|
||||||
|
const durationForRow = durations[k]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FinishedDownloadsGalleryCard
|
<FinishedDownloadsGalleryCard
|
||||||
@ -232,7 +233,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
postworkByFile={postworkByFile}
|
postworkByFile={postworkByFile}
|
||||||
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
getPostworkSummaryForOutput={getPostworkSummaryForOutput}
|
||||||
renderPostworkBadge={renderPostworkBadge}
|
renderPostworkBadge={renderPostworkBadge}
|
||||||
durations={durations}
|
durationSeconds={durationForRow}
|
||||||
teaserState={teaserState}
|
teaserState={teaserState}
|
||||||
keyFor={keyFor}
|
keyFor={keyFor}
|
||||||
baseName={baseName}
|
baseName={baseName}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
} from './teaserPlayback'
|
} from './teaserPlayback'
|
||||||
import Checkbox from './Checkbox'
|
import Checkbox from './Checkbox'
|
||||||
import { parseJobMeta } from './previewSprite'
|
import { parseJobMeta } from './previewSprite'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RecordJob[]
|
rows: RecordJob[]
|
||||||
@ -365,8 +366,17 @@ export default function FinishedDownloadsTableView({
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="min-w-0 flex items-center gap-1.5">
|
||||||
{model}
|
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{model}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={modelsByKey[modelKey]?.gender}
|
||||||
|
sex={modelsByKey[modelKey]?.sex}
|
||||||
|
subgender={modelsByKey[modelKey]?.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPostworkBadge && renderPostworkBadge ? (
|
{showPostworkBadge && renderPostworkBadge ? (
|
||||||
|
|||||||
@ -685,8 +685,8 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const teaserSrc = useMemo(() => {
|
const teaserSrc = useMemo(() => {
|
||||||
if (!previewId) return ''
|
if (!previewId) return ''
|
||||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
|
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
|
||||||
}, [previewId, v])
|
}, [previewId])
|
||||||
|
|
||||||
// --- Inline Video sichtbar?
|
// --- Inline Video sichtbar?
|
||||||
const showingInlineVideo =
|
const showingInlineVideo =
|
||||||
|
|||||||
@ -377,6 +377,34 @@ function pill(cls: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function heroOverlayPill(cls: string) {
|
||||||
|
return cn(
|
||||||
|
'inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-semibold text-white',
|
||||||
|
'ring-1 backdrop-blur-md shadow-sm',
|
||||||
|
'[text-shadow:0_1px_1px_rgba(0,0,0,0.45)]',
|
||||||
|
cls
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function heroStatusPill(show: string) {
|
||||||
|
switch (show) {
|
||||||
|
case 'public':
|
||||||
|
return heroOverlayPill('bg-emerald-500/85 ring-emerald-300/25')
|
||||||
|
case 'private':
|
||||||
|
return heroOverlayPill('bg-fuchsia-500/85 ring-fuchsia-300/25')
|
||||||
|
case 'hidden':
|
||||||
|
return heroOverlayPill('bg-slate-900/70 ring-white/15')
|
||||||
|
case 'away':
|
||||||
|
return heroOverlayPill('bg-amber-500/85 ring-amber-300/25')
|
||||||
|
default:
|
||||||
|
return heroOverlayPill('bg-black/55 ring-white/15')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function heroNewPill() {
|
||||||
|
return heroOverlayPill('bg-amber-500/90 ring-amber-300/25')
|
||||||
|
}
|
||||||
|
|
||||||
const previewBlurCls = (blur?: boolean) =>
|
const previewBlurCls = (blur?: boolean) =>
|
||||||
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
blur ? 'blur-md scale-[1.03] brightness-90' : ''
|
||||||
|
|
||||||
@ -1400,30 +1428,6 @@ export default function ModelDetails({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
||||||
{showPill ? (
|
|
||||||
<span
|
|
||||||
className={pill(
|
|
||||||
showLabel === 'public'
|
|
||||||
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/20'
|
|
||||||
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showPill}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{effectiveRoom?.is_new ? (
|
|
||||||
<span
|
|
||||||
className={pill(
|
|
||||||
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
NEW
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
width="max-w-6xl"
|
width="max-w-6xl"
|
||||||
@ -1483,23 +1487,13 @@ export default function ModelDetails({
|
|||||||
{/* Pills */}
|
{/* Pills */}
|
||||||
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
|
<div className="absolute left-3 top-3 flex flex-wrap items-center gap-2">
|
||||||
{showPill ? (
|
{showPill ? (
|
||||||
<span
|
<span className={heroStatusPill(showLabel)}>
|
||||||
className={pill(
|
|
||||||
showLabel === 'public'
|
|
||||||
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 backdrop-blur dark:text-emerald-200 dark:ring-emerald-400/20'
|
|
||||||
: 'bg-gray-500/10 text-gray-900 ring-gray-200 backdrop-blur dark:text-gray-200 dark:ring-white/15'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{showPill}
|
{showPill}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{effectiveRoom?.is_new ? (
|
{effectiveRoom?.is_new ? (
|
||||||
<span
|
<span className={heroNewPill()}>
|
||||||
className={pill(
|
|
||||||
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
NEW
|
NEW
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@ -1870,9 +1864,33 @@ export default function ModelDetails({
|
|||||||
{/* Top row: name/username links + action icons rechts */}
|
{/* Top row: name/username links + action icons rechts */}
|
||||||
<div className="absolute left-2 right-2 top-2 z-20 flex items-start justify-between gap-3">
|
<div className="absolute left-2 right-2 top-2 z-20 flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1 pr-2">
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
<div className="flex min-w-0 items-center">
|
||||||
<div className="truncate text-sm font-semibold text-white drop-shadow">
|
<div className="inline-flex min-w-0 max-w-full items-center gap-1.5">
|
||||||
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
<span className="min-w-0 truncate text-sm font-semibold text-white drop-shadow">
|
||||||
|
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{showPill ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
heroStatusPill(showLabel),
|
||||||
|
'shrink-0 px-2 py-0.5 text-[10px] shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showPill}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{effectiveRoom?.is_new ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
heroNewPill(),
|
||||||
|
'shrink-0 px-2 py-0.5 text-[10px] shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -19,15 +19,14 @@ function normalizeGender(
|
|||||||
|
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
|
|
||||||
if (raw.includes('couple') || raw.includes('pair')) {
|
if (raw.includes('couple') || raw.includes('pair') || raw === 'c') {
|
||||||
return 'couple'
|
return 'couple'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
raw.includes('trans') ||
|
raw.includes('trans') ||
|
||||||
raw.includes('transgender') ||
|
raw.includes('transgender') ||
|
||||||
raw.includes('ts') ||
|
raw === 't'
|
||||||
raw.includes('shemale')
|
|
||||||
) {
|
) {
|
||||||
return 'trans'
|
return 'trans'
|
||||||
}
|
}
|
||||||
@ -35,7 +34,9 @@ function normalizeGender(
|
|||||||
if (
|
if (
|
||||||
raw.includes('female') ||
|
raw.includes('female') ||
|
||||||
raw.includes('girl') ||
|
raw.includes('girl') ||
|
||||||
raw.includes('woman')
|
raw.includes('woman') ||
|
||||||
|
raw === 'f' ||
|
||||||
|
raw === 'w'
|
||||||
) {
|
) {
|
||||||
return 'female'
|
return 'female'
|
||||||
}
|
}
|
||||||
@ -43,7 +44,8 @@ function normalizeGender(
|
|||||||
if (
|
if (
|
||||||
raw.includes('male') ||
|
raw.includes('male') ||
|
||||||
raw.includes('boy') ||
|
raw.includes('boy') ||
|
||||||
raw.includes('man')
|
raw.includes('man') ||
|
||||||
|
raw === 'm'
|
||||||
) {
|
) {
|
||||||
return 'male'
|
return 'male'
|
||||||
}
|
}
|
||||||
@ -153,13 +155,22 @@ export default function ModelGenderIcon({
|
|||||||
|
|
||||||
const { label, Icon } = map[kind]
|
const { label, Icon } = map[kind]
|
||||||
|
|
||||||
|
const toneClass =
|
||||||
|
kind === 'female'
|
||||||
|
? 'bg-pink-100 text-pink-700 ring-pink-200 dark:bg-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25'
|
||||||
|
: kind === 'male'
|
||||||
|
? 'bg-sky-100 text-sky-700 ring-sky-200 dark:bg-sky-500/15 dark:text-sky-200 dark:ring-sky-400/25'
|
||||||
|
: kind === 'trans'
|
||||||
|
? 'bg-violet-100 text-violet-700 ring-violet-200 dark:bg-violet-500/15 dark:text-violet-200 dark:ring-violet-400/25'
|
||||||
|
: 'bg-gradient-to-r from-sky-100 to-pink-100 text-fuchsia-700 ring-fuchsia-200 dark:from-sky-500/15 dark:to-pink-500/15 dark:text-pink-200 dark:ring-pink-400/25'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
|
'inline-flex shrink-0 items-center justify-center rounded-full ring-1 ring-inset',
|
||||||
variant === 'hero'
|
variant === 'hero'
|
||||||
? 'size-5 bg-black/30 text-white ring-white/20 backdrop-blur'
|
? cn('size-5 backdrop-blur shadow-sm', toneClass)
|
||||||
: 'size-5 bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-white dark:ring-white/15',
|
: cn('size-5', toneClass),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
title={label}
|
title={label}
|
||||||
|
|||||||
@ -57,6 +57,9 @@ export default function ModelPreview({
|
|||||||
const [liveSrc, setLiveSrc] = useState('')
|
const [liveSrc, setLiveSrc] = useState('')
|
||||||
const [hoverOpen, setHoverOpen] = useState(false)
|
const [hoverOpen, setHoverOpen] = useState(false)
|
||||||
|
|
||||||
|
const [localRefreshNonce, setLocalRefreshNonce] = useState(0)
|
||||||
|
const [usingFallback, setUsingFallback] = useState(false)
|
||||||
|
|
||||||
const [livePreparing, setLivePreparing] = useState(false)
|
const [livePreparing, setLivePreparing] = useState(false)
|
||||||
const [liveReady, setLiveReady] = useState(false)
|
const [liveReady, setLiveReady] = useState(false)
|
||||||
|
|
||||||
@ -84,24 +87,49 @@ export default function ModelPreview({
|
|||||||
|
|
||||||
const previewSrc = useMemo(() => {
|
const previewSrc = useMemo(() => {
|
||||||
const explicit = String(initialSrc ?? '').trim()
|
const explicit = String(initialSrc ?? '').trim()
|
||||||
if (explicit) {
|
const base = explicit || `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
||||||
return explicit
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
const version =
|
||||||
|
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
|
||||||
|
? `${refreshKey}-${localRefreshNonce}`
|
||||||
|
: localRefreshNonce > 0
|
||||||
|
? String(localRefreshNonce)
|
||||||
|
: ''
|
||||||
|
|
||||||
if (typeof refreshKey === 'number') {
|
if (!version) return base
|
||||||
return `${base}&v=${refreshKey}`
|
|
||||||
}
|
|
||||||
|
|
||||||
return base
|
return base.includes('?')
|
||||||
}, [initialSrc, jobId, refreshKey])
|
? `${base}&v=${encodeURIComponent(version)}`
|
||||||
|
: `${base}?v=${encodeURIComponent(version)}`
|
||||||
|
}, [initialSrc, jobId, refreshKey, localRefreshNonce])
|
||||||
|
|
||||||
const fallbackImgSrc = useMemo(() => {
|
const fallbackImgSrc = useMemo(() => {
|
||||||
const s = String(fallbackSrc ?? '').trim()
|
const s = String(fallbackSrc ?? '').trim()
|
||||||
if (s) return s
|
const base = s || `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
||||||
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
|
||||||
}, [fallbackSrc, jobId])
|
const version =
|
||||||
|
typeof refreshKey === 'number' && Number.isFinite(refreshKey)
|
||||||
|
? `${refreshKey}-${localRefreshNonce}`
|
||||||
|
: localRefreshNonce > 0
|
||||||
|
? String(localRefreshNonce)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (!version) return base
|
||||||
|
|
||||||
|
return base.includes('?')
|
||||||
|
? `${base}&v=${encodeURIComponent(version)}`
|
||||||
|
: `${base}?v=${encodeURIComponent(version)}`
|
||||||
|
}, [fallbackSrc, jobId, refreshKey, localRefreshNonce])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) return
|
||||||
|
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
setLocalRefreshNonce((n) => n + 1)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [inView, jobId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
@ -170,12 +198,21 @@ export default function ModelPreview({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
|
setUsingFallback(false)
|
||||||
setImgSrc(previewSrc)
|
setImgSrc(previewSrc)
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
}, [jobId, previewSrc])
|
}, [jobId, previewSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!usingFallback) return
|
||||||
|
if (!fallbackImgSrc) return
|
||||||
|
|
||||||
|
setImgError(false)
|
||||||
|
setImgSrc(fallbackImgSrc)
|
||||||
|
}, [usingFallback, fallbackImgSrc])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
@ -287,21 +324,26 @@ export default function ModelPreview({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
alt=""
|
alt=""
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
setImgError(false)
|
setImgError(false)
|
||||||
|
|
||||||
|
const current = String(imgSrc || '').trim()
|
||||||
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
setUsingFallback(Boolean(fallback && current === fallback))
|
||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
const current = String(imgSrc || '').trim()
|
const current = String(imgSrc || '').trim()
|
||||||
const fallback = String(fallbackImgSrc || '').trim()
|
const fallback = String(fallbackImgSrc || '').trim()
|
||||||
|
|
||||||
// Erst von preview.jpg auf Provider-Fallback wechseln
|
|
||||||
if (fallback && current !== fallback) {
|
if (fallback && current !== fallback) {
|
||||||
|
setUsingFallback(true)
|
||||||
|
setImgError(false)
|
||||||
setImgSrc(fallback)
|
setImgSrc(fallback)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn selbst das Fallback nicht lädt: endgültig Fehler
|
|
||||||
setImgError(true)
|
setImgError(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import TagBadge from './TagBadge'
|
|||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import type { RecordJob } from '../../types'
|
import type { RecordJob } from '../../types'
|
||||||
import TextInput from './TextInput'
|
import TextInput from './TextInput'
|
||||||
|
import ModelGenderIcon from './ModelGenderIcon'
|
||||||
|
|
||||||
type ParsedModel = {
|
type ParsedModel = {
|
||||||
input: string
|
input: string
|
||||||
@ -51,6 +52,10 @@ export type StoredModel = {
|
|||||||
profileImageCached?: string
|
profileImageCached?: string
|
||||||
profileImageUpdatedAt?: string
|
profileImageUpdatedAt?: string
|
||||||
|
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
|
|
||||||
watching: boolean
|
watching: boolean
|
||||||
favorite: boolean
|
favorite: boolean
|
||||||
hot: boolean
|
hot: boolean
|
||||||
@ -741,7 +746,16 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="font-medium truncate">{m.modelKey}</div>
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<div className="font-medium truncate">{m.modelKey}</div>
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={m.gender}
|
||||||
|
sex={m.sex}
|
||||||
|
subgender={m.subgender}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{href ? (
|
{href ? (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
|
||||||
@ -1320,11 +1334,20 @@ export default function ModelsTab({ onAddToDownloads }: ModelsTabProps) {
|
|||||||
|
|
||||||
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
|
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
|
||||||
<div
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
<div
|
||||||
title={m.modelKey}
|
className="truncate text-sm font-semibold text-white drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"
|
||||||
>
|
title={m.modelKey}
|
||||||
{m.modelKey}
|
>
|
||||||
|
{m.modelKey}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelGenderIcon
|
||||||
|
gender={m.gender}
|
||||||
|
sex={m.sex}
|
||||||
|
subgender={m.subgender}
|
||||||
|
className="shrink-0 text-white"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{m.host ? (
|
{m.host ? (
|
||||||
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
<div className="truncate text-[11px] text-white/70 drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
|
||||||
|
|||||||
@ -57,9 +57,9 @@ function PageButton({
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
'relative inline-flex h-10 min-w-10 items-center justify-center px-3 text-sm font-semibold focus:z-20 focus:outline-offset-0',
|
||||||
roundedCls,
|
roundedCls,
|
||||||
disabled ? 'opacity-100 cursor-not-allowed' : 'cursor-pointer',
|
disabled
|
||||||
'bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50',
|
? 'opacity-100 cursor-not-allowed bg-white text-gray-400 inset-ring inset-ring-gray-200 dark:bg-slate-900 dark:text-gray-500 dark:inset-ring-slate-800'
|
||||||
'dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
|
: 'cursor-pointer bg-white text-gray-900 inset-ring inset-ring-gray-300 hover:bg-gray-50 dark:bg-slate-800 dark:text-gray-200 dark:inset-ring-slate-700 dark:hover:bg-slate-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
||||||
const assetsAbortRef = useRef<AbortController | null>(null)
|
const assetsAbortRef = useRef<AbortController | null>(null)
|
||||||
|
const assetsCancelRequestedRef = useRef(false)
|
||||||
const [dbModalOpen, setDbModalOpen] = useState(false)
|
const [dbModalOpen, setDbModalOpen] = useState(false)
|
||||||
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
||||||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
||||||
@ -258,7 +259,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const assetsLabel = String(assets?.currentLabel || '').trim()
|
const assetsLabel = String(assets?.currentLabel || '').trim()
|
||||||
const assetsPhase = String(assets?.currentPhase || '').trim()
|
const assetsPhase = String(assets?.currentPhase || '').trim()
|
||||||
|
|
||||||
if (assets?.running) {
|
if (assets?.queued) {
|
||||||
|
setAssetsTask((t) => ({
|
||||||
|
...t,
|
||||||
|
status: 'running',
|
||||||
|
title: 'Assets generieren',
|
||||||
|
text: String(assets.text || 'Wartet…'),
|
||||||
|
done: Number(assets.done ?? 0),
|
||||||
|
total: Number(assets.total ?? 0),
|
||||||
|
err: undefined,
|
||||||
|
cancellable: false,
|
||||||
|
fading: false,
|
||||||
|
}))
|
||||||
|
} else if (assets?.running) {
|
||||||
setAssetsTask((t) => ({
|
setAssetsTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@ -278,12 +291,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
assetsError.toLowerCase() === 'cancelled' ||
|
assetsError.toLowerCase() === 'cancelled' ||
|
||||||
assetsError.toLowerCase() === 'canceled'
|
assetsError.toLowerCase() === 'canceled'
|
||||||
|
|
||||||
|
if (cancelled && assetsCancelRequestedRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAssetsTask((t) => {
|
setAssetsTask((t) => {
|
||||||
if (cancelled && (t.status === 'idle' || t.fading)) {
|
if (cancelled && (t.status === 'idle' || t.fading)) {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
const next: TaskItem = {
|
return {
|
||||||
...t,
|
...t,
|
||||||
status: cancelled ? 'cancelled' : 'error',
|
status: cancelled ? 'cancelled' : 'error',
|
||||||
title: 'Assets generieren',
|
title: 'Assets generieren',
|
||||||
@ -294,10 +311,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
cancellable: false,
|
cancellable: false,
|
||||||
fading: t.fading ?? false,
|
fading: t.fading ?? false,
|
||||||
}
|
}
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
})
|
||||||
} else if (assets?.text || assets?.finishedAt) {
|
} else if (assets?.finishedAt) {
|
||||||
setAssetsTask((t) => ({
|
setAssetsTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'done',
|
status: 'done',
|
||||||
@ -654,7 +669,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3500, fadeMs = 500) {
|
function fadeOutTask(setter: React.Dispatch<React.SetStateAction<TaskItem>>, delayMs = 3000, fadeMs = 500) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setter((t) => ({ ...t, fading: true }))
|
setter((t) => ({ ...t, fading: true }))
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@ -672,7 +687,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}, delayMs)
|
}, delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
function fadeOutRegenerateTask(file: string, delayMs = 3500, fadeMs = 500) {
|
function fadeOutRegenerateTask(file: string, delayMs = 3000, fadeMs = 500) {
|
||||||
const id = regenerateTaskId(file)
|
const id = regenerateTaskId(file)
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@ -769,35 +784,48 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cancelAssetsTask() {
|
async function cancelAssetsTask() {
|
||||||
const ac = assetsAbortRef.current
|
assetsCancelRequestedRef.current = true
|
||||||
assetsAbortRef.current = null
|
|
||||||
|
|
||||||
// UI sofort
|
const ac = assetsAbortRef.current
|
||||||
setAssetsTask((t: TaskItem) => ({
|
assetsAbortRef.current = null
|
||||||
...t,
|
|
||||||
status: 'cancelled',
|
|
||||||
title: 'Assets generieren',
|
|
||||||
text: '',
|
|
||||||
err: undefined,
|
|
||||||
cancellable: false,
|
|
||||||
fading: false,
|
|
||||||
}))
|
|
||||||
|
|
||||||
fadeOutTask(setAssetsTask)
|
// UI ruhig ausblenden statt "Abgebrochen" sichtbar zu zeigen
|
||||||
|
setAssetsTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
cancellable: false,
|
||||||
|
fading: true,
|
||||||
|
}))
|
||||||
|
|
||||||
if (ac) {
|
window.setTimeout(() => {
|
||||||
ac.abort()
|
setAssetsTask((t: TaskItem) => ({
|
||||||
return
|
...t,
|
||||||
|
status: 'idle',
|
||||||
|
title: 'Assets generieren',
|
||||||
|
text: '',
|
||||||
|
err: undefined,
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
startedAtMs: 0,
|
||||||
|
}))
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
if (ac) {
|
||||||
|
ac.abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/tasks/generate-assets', {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'no-store' as any,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback (z.B. nach Reload, wenn kein Controller existiert):
|
|
||||||
try {
|
|
||||||
await fetch('/api/tasks/generate-assets', { method: 'DELETE', cache: 'no-store' as any })
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancelRegenerateAssetsTask(file: string) {
|
async function cancelRegenerateAssetsTask(file: string) {
|
||||||
const cleanFile = String(file || '').trim()
|
const cleanFile = String(file || '').trim()
|
||||||
if (!cleanFile) return
|
if (!cleanFile) return
|
||||||
@ -920,7 +948,23 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
stopUrl="/api/tasks/generate-assets"
|
stopUrl="/api/tasks/generate-assets"
|
||||||
sseUrl="/api/events/stream"
|
sseUrl="/api/events/stream"
|
||||||
onFinished={onAssetsGenerated}
|
onFinished={onAssetsGenerated}
|
||||||
|
onQueued={() => {
|
||||||
|
assetsCancelRequestedRef.current = false
|
||||||
|
setAssetsTask((t: TaskItem) => ({
|
||||||
|
...t,
|
||||||
|
status: 'queued',
|
||||||
|
title: 'Assets generieren',
|
||||||
|
text: 'Wartet…',
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
|
fading: false,
|
||||||
|
startedAtMs: t.startedAtMs || Date.now(),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
onStart={(ac) => {
|
onStart={(ac) => {
|
||||||
|
assetsCancelRequestedRef.current = false
|
||||||
assetsAbortRef.current = ac
|
assetsAbortRef.current = ac
|
||||||
setAssetsTask((t: TaskItem) => ({
|
setAssetsTask((t: TaskItem) => ({
|
||||||
...t,
|
...t,
|
||||||
@ -930,6 +974,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
err: undefined,
|
err: undefined,
|
||||||
|
cancellable: true,
|
||||||
fading: false,
|
fading: false,
|
||||||
startedAtMs: t.startedAtMs || Date.now(),
|
startedAtMs: t.startedAtMs || Date.now(),
|
||||||
}))
|
}))
|
||||||
@ -955,6 +1000,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}}
|
}}
|
||||||
onCancelled={() => {
|
onCancelled={() => {
|
||||||
assetsAbortRef.current = null
|
assetsAbortRef.current = null
|
||||||
|
|
||||||
|
if (assetsCancelRequestedRef.current) {
|
||||||
|
assetsCancelRequestedRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setAssetsTask((t: TaskItem) => ({
|
setAssetsTask((t: TaskItem) => ({
|
||||||
...t,
|
...t,
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
@ -983,7 +1034,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
startingLabel="Läuft…"
|
startingLabel="Läuft…"
|
||||||
onTrigger={cleanupSmallDone}
|
onTrigger={cleanupSmallDone}
|
||||||
busy={cleaning}
|
busy={cleaning}
|
||||||
disabled={saving || !value.autoDeleteSmallDownloads}
|
disabled={saving}
|
||||||
onError={(message) => {
|
onError={(message) => {
|
||||||
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
// Optional: zusätzlicher Fallback für Startfehler-Anzeige direkt im Task
|
||||||
setErr(message)
|
setErr(message)
|
||||||
@ -1117,7 +1168,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5 mb-2">
|
||||||
<LabeledSwitch
|
<LabeledSwitch
|
||||||
checked={!!value.enableConcurrentDownloadsLimit}
|
checked={!!value.enableConcurrentDownloadsLimit}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
|
|||||||
@ -41,6 +41,7 @@ type Props = {
|
|||||||
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
|
/** Callback-Hooks (nur für API/SSE-Variante relevant) */
|
||||||
onFinished?: () => void
|
onFinished?: () => void
|
||||||
onStart?: (ac: AbortController) => void
|
onStart?: (ac: AbortController) => void
|
||||||
|
onQueued?: () => void
|
||||||
onProgress?: (p: Progress) => void
|
onProgress?: (p: Progress) => void
|
||||||
onDone?: () => void
|
onDone?: () => void
|
||||||
onCancelled?: () => void
|
onCancelled?: () => void
|
||||||
@ -85,6 +86,7 @@ export default function Task({
|
|||||||
busy = false,
|
busy = false,
|
||||||
onFinished,
|
onFinished,
|
||||||
onStart,
|
onStart,
|
||||||
|
onQueued,
|
||||||
onProgress,
|
onProgress,
|
||||||
onDone,
|
onDone,
|
||||||
onCancelled,
|
onCancelled,
|
||||||
@ -155,12 +157,13 @@ export default function Task({
|
|||||||
|
|
||||||
if (prev && !cur) {
|
if (prev && !cur) {
|
||||||
const errText = String(state?.error ?? '').trim()
|
const errText = String(state?.error ?? '').trim()
|
||||||
|
const errTextNorm = errText.toLowerCase()
|
||||||
|
|
||||||
// Reset pro Run
|
// Reset pro Run
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
armedRef.current = false
|
armedRef.current = false
|
||||||
|
|
||||||
if (cancelledRef.current || errText === 'abgebrochen') {
|
if (cancelledRef.current || errTextNorm === 'abgebrochen') {
|
||||||
cancelledRef.current = false
|
cancelledRef.current = false
|
||||||
onCancelled?.()
|
onCancelled?.()
|
||||||
} else if (errText) {
|
} else if (errText) {
|
||||||
@ -191,9 +194,10 @@ export default function Task({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errText = String(st?.error ?? '').trim()
|
const errText = String(st?.error ?? '').trim()
|
||||||
|
const errTextNorm = errText.toLowerCase()
|
||||||
|
|
||||||
// Abbruch ist kein "Fehler"-Event für die UI
|
// Abbruch ist kein "Fehler"-Event für die UI
|
||||||
if (errText && errText !== 'abgebrochen' && errText !== lastErrorRef.current) {
|
if (errText && errTextNorm !== 'abgebrochen' && errText !== lastErrorRef.current) {
|
||||||
lastErrorRef.current = errText
|
lastErrorRef.current = errText
|
||||||
onErrorRef.current?.(errText)
|
onErrorRef.current?.(errText)
|
||||||
}
|
}
|
||||||
@ -205,13 +209,16 @@ export default function Task({
|
|||||||
async function start() {
|
async function start() {
|
||||||
if (busy) return
|
if (busy) return
|
||||||
if (state?.running) return
|
if (state?.running) return
|
||||||
|
if (starting) return
|
||||||
|
|
||||||
setStartError(null)
|
setStartError(null)
|
||||||
setStarting(true)
|
setStarting(true)
|
||||||
cancelledRef.current = false
|
cancelledRef.current = false
|
||||||
lastErrorRef.current = ''
|
lastErrorRef.current = ''
|
||||||
|
|
||||||
// 1) Lokaler Task-Modus (z.B. Cleanup)
|
onQueued?.()
|
||||||
|
|
||||||
|
// 1) Lokaler Task-Modus
|
||||||
if (onTrigger) {
|
if (onTrigger) {
|
||||||
try {
|
try {
|
||||||
await onTrigger()
|
await onTrigger()
|
||||||
@ -225,7 +232,7 @@ export default function Task({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) API/SSE-Task-Modus (wie Assets)
|
// 2) API/SSE-Task-Modus
|
||||||
if (!startUrl) {
|
if (!startUrl) {
|
||||||
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
|
const msg = 'Task ist nicht konfiguriert (startUrl fehlt).'
|
||||||
setStartError(msg)
|
setStartError(msg)
|
||||||
@ -234,17 +241,16 @@ export default function Task({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controller vorbereiten, aber TaskList erst nach erfolgreichem Start armieren
|
|
||||||
const ac = ensureControllerCreated()
|
const ac = ensureControllerCreated()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const st = await fetchJSON<TaskState>(startUrl, { method: 'POST' })
|
const st = await fetchJSON<TaskState & { queued?: boolean }>(startUrl, { method: 'POST' })
|
||||||
setState(st)
|
setState(st)
|
||||||
|
|
||||||
// TaskList jetzt aktivieren
|
// Nur wenn der Task wirklich schon läuft
|
||||||
armTaskList(ac)
|
|
||||||
|
|
||||||
if (st?.running) {
|
if (st?.running) {
|
||||||
|
armTaskList(ac)
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
done: st?.done ?? 0,
|
done: st?.done ?? 0,
|
||||||
total: st?.total ?? 0,
|
total: st?.total ?? 0,
|
||||||
@ -252,7 +258,6 @@ export default function Task({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Start fehlgeschlagen -> Controller/Flags zurücksetzen
|
|
||||||
abortRef.current = null
|
abortRef.current = null
|
||||||
armedRef.current = false
|
armedRef.current = false
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
export type TaskItem = {
|
export type TaskItem = {
|
||||||
id: string
|
id: string
|
||||||
status: 'idle' | 'running' | 'done' | 'error' | 'cancelled'
|
status: 'idle' | 'queued' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
title: string
|
title: string
|
||||||
text?: string
|
text?: string
|
||||||
done?: number
|
done?: number
|
||||||
@ -60,26 +60,30 @@ export default function TaskList({ tasks, onCancel }: Props) {
|
|||||||
const hasProgress = isRunning && (t.total ?? 0) > 0
|
const hasProgress = isRunning && (t.total ?? 0) > 0
|
||||||
const title = (t.title ?? '').trim()
|
const title = (t.title ?? '').trim()
|
||||||
const suffix = (t.text ?? '').trim()
|
const suffix = (t.text ?? '').trim()
|
||||||
|
const isQueued = t.status === 'queued'
|
||||||
|
const canCancel = (isRunning || isQueued) && t.cancellable && onCancel
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={
|
className={
|
||||||
"rounded-xl border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5 " +
|
'rounded-xl border border-gray-200 bg-white px-2 py-2 dark:border-white/10 dark:bg-white/5 ' +
|
||||||
"transition-opacity duration-500 " +
|
'transition-opacity duration-500 ' +
|
||||||
(t.fading ? "opacity-0" : "opacity-100")
|
(t.fading ? 'opacity-0' : 'opacity-100')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-stretch gap-1 sm:min-h-[24px] sm:items-center">
|
||||||
{/* Left icon: Cancel X (running+cancellable) OR green check (done) */}
|
{/* Left action / icon column */}
|
||||||
<div className="shrink-0">
|
<div className="shrink-0 self-stretch flex">
|
||||||
{isRunning && t.cancellable && onCancel ? (
|
{canCancel ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCancel?.(t.id)}
|
onClick={() => onCancel?.(t.id)}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md
|
className="
|
||||||
text-red-700 hover:bg-red-50 hover:text-red-900
|
inline-flex w-9 items-center justify-center self-stretch rounded-lg
|
||||||
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200"
|
text-red-700 hover:bg-red-50 hover:text-red-900
|
||||||
|
dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200
|
||||||
|
"
|
||||||
title="Abbrechen"
|
title="Abbrechen"
|
||||||
aria-label="Task abbrechen"
|
aria-label="Task abbrechen"
|
||||||
>
|
>
|
||||||
@ -87,67 +91,80 @@ export default function TaskList({ tasks, onCancel }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
) : t.status === 'done' ? (
|
) : t.status === 'done' ? (
|
||||||
<span
|
<span
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-green-700 dark:text-green-300"
|
className="inline-flex w-9 items-center justify-center self-stretch rounded-lg text-green-700 dark:text-green-300"
|
||||||
title="Fertig"
|
title="Fertig"
|
||||||
aria-label="Fertig"
|
aria-label="Fertig"
|
||||||
>
|
>
|
||||||
✓
|
✓
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-block h-7 w-7" />
|
<span className="inline-flex w-9 self-stretch" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* One-line row: title (optional) + status + progress */}
|
{/* Right content */}
|
||||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
<div className="min-w-0 flex-1 flex flex-col justify-center">
|
||||||
<div className="min-w-0 truncate text-sm font-semibold text-gray-900 dark:text-white">
|
<div className="flex items-start gap-2 sm:items-center">
|
||||||
{title || 'Aufgabe'}
|
<div className="min-w-0 flex-1">
|
||||||
{suffix ? (
|
{/* Mobile: Title oben */}
|
||||||
<span className="font-normal text-gray-600 dark:text-gray-300">
|
<div className="sm:hidden truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{' · '}{suffix}
|
{title || 'Aufgabe'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Title + suffix in einer Zeile */}
|
||||||
|
<div className="hidden sm:block min-w-0 truncate text-sm text-gray-900 dark:text-white">
|
||||||
|
<span className="font-semibold">{title || 'Aufgabe'}</span>
|
||||||
|
{suffix ? (
|
||||||
|
<span className="ml-1 font-normal text-gray-600 dark:text-gray-300">
|
||||||
|
· {suffix}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t.status === 'done' ? (
|
||||||
|
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
|
||||||
|
Fertig
|
||||||
</span>
|
</span>
|
||||||
|
) : t.status === 'cancelled' ? (
|
||||||
|
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
|
||||||
|
Abgebrochen
|
||||||
|
</span>
|
||||||
|
) : t.status === 'error' ? (
|
||||||
|
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
|
||||||
|
Fehler
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasProgress ? (
|
||||||
|
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
|
||||||
|
<span className="tabular-nums">{p}%</span>
|
||||||
|
|
||||||
|
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
||||||
|
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status badge (running bewusst ohne Badge) */}
|
{/* Mobile: suffix unter dem Title */}
|
||||||
{t.status === 'done' ? (
|
{suffix ? (
|
||||||
<span className="shrink-0 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[11px] font-semibold text-green-800 ring-1 ring-inset ring-green-200 dark:bg-green-500/20 dark:text-green-200 dark:ring-green-400/30">
|
<div
|
||||||
Fertig
|
className="mt-0.5 min-w-0 truncate text-xs text-gray-600 dark:text-gray-300 sm:hidden"
|
||||||
</span>
|
title={suffix}
|
||||||
) : t.status === 'cancelled' ? (
|
>
|
||||||
<span className="shrink-0 inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-semibold text-gray-700 ring-1 ring-inset ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
|
{suffix}
|
||||||
Abgebrochen
|
</div>
|
||||||
</span>
|
|
||||||
) : t.status === 'error' ? (
|
|
||||||
<span className="shrink-0 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-[11px] font-semibold text-red-800 ring-1 ring-inset ring-red-200 dark:bg-red-500/20 dark:text-red-200 dark:ring-red-400/30">
|
|
||||||
Fehler
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Spacer */}
|
{t.status === 'error' && t.err ? (
|
||||||
<div className="flex-1" />
|
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
||||||
|
{t.err}
|
||||||
{/* Progress compact on same line */}
|
|
||||||
{hasProgress ? (
|
|
||||||
<div className="shrink-0 flex items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
|
|
||||||
<span className="tabular-nums">{t.done ?? 0}/{t.total ?? 0}</span>
|
|
||||||
<span className="tabular-nums">{p}%</span>
|
|
||||||
|
|
||||||
{/* Progressbar: auf Mobile ausblenden, ab sm anzeigen */}
|
|
||||||
<div className="hidden sm:block h-2 w-40 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
|
|
||||||
<div className="h-full bg-indigo-500" style={{ width: `${p}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error text optional (unter der Zeile) */}
|
|
||||||
{t.status === 'error' && t.err ? (
|
|
||||||
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
|
|
||||||
{t.err}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -228,6 +228,9 @@ export type StoredModelFlags = {
|
|||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
watching?: boolean | null
|
watching?: boolean | null
|
||||||
tags?: string
|
tags?: string
|
||||||
|
gender?: string | null
|
||||||
|
sex?: string | null
|
||||||
|
subgender?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GalleryModelFlags = StoredModelFlags & {
|
export type GalleryModelFlags = StoredModelFlags & {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user