fixed autostart

This commit is contained in:
Linrador 2026-04-08 13:04:01 +02:00
parent ff341755c6
commit e5f506ec45
12 changed files with 1150 additions and 751 deletions

View File

@ -808,10 +808,8 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return nil
}
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred teaser failed for %s: %w", videoPath, err)
}
// NUR noch langsame Background-Schritte:
// 1) Sprite
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err)
}
@ -821,6 +819,7 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string)
return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id)
}
// 2) KI-Analyse
analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw")
if err != nil {
return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err)

View File

@ -117,7 +117,7 @@ func generatePreviewSpriteJPG(
"-i", videoPath,
"-an",
"-sn",
"-threads", "0",
"-threads", "2",
"-vf", vf,
"-frames:v", "1",
"-update", "1",

View File

@ -275,6 +275,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
showByUser := map[string]string{}
imageByUser := map[string]string{}
seenInAPI := map[string]bool{}
for _, r := range rooms {
@ -283,19 +284,26 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
seenInAPI[u] = true
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
imageByUser[u] = selectBestRoomImageURL(r)
}
// laufende Jobs sammeln
running := map[string]bool{}
runningByUser := map[string]*RecordJob{}
jobsMu.RLock()
for _, j := range jobs {
if j == nil {
continue
}
if !isActiveRecordingJob(j) {
continue
}
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
continue
}
u := chaturbateUserFromURL(j.SourceURL)
if u != "" {
running[u] = true
runningByUser[u] = j
}
}
jobsMu.RUnlock()
@ -348,7 +356,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if !ok {
continue
}
if running[it.userKey] {
if runningByUser[it.userKey] != nil {
continue
}
@ -364,68 +372,86 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued = nextQueued
now := time.Now()
nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser))
// ------------------------------------------------------------
// 1) watched + API sagt public => normal enqueue
// ------------------------------------------------------------
for user, m := range watchedByUser {
show := strings.ToLower(strings.TrimSpace(showByUser[user]))
if show == "" {
continue
}
if !strings.Contains(show, "public") {
continue
}
if running[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
continue
}
u := resolveChaturbateURL(m)
if u == "" {
continue
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
queued[user] = true
show := normalizePendingShowServer(showByUser[user])
img := strings.TrimSpace(imageByUser[user])
switch show {
case "public":
// public => kein Pending-Eintrag
if runningByUser[user] != nil {
continue
}
if queued[user] {
continue
}
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
continue
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
queued[user] = true
case "private", "hidden", "away":
// laufenden watched-Download beenden und auf public warten
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
if runningJob := runningByUser[user]; runningJob != nil {
stopJobsInternal([]*RecordJob{runningJob})
}
default:
// offline / unknown / nicht in API
nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli()
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "probe_retry",
NextProbeAtMs: nextProbeAtMs,
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
if runningByUser[user] != nil {
continue
}
if queued[user] {
continue
}
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
continue
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
queued[user] = true
}
}
// ------------------------------------------------------------
// 2) watched + NICHT in API => blind best-effort try vormerken
// ------------------------------------------------------------
for user, m := range watchedByUser {
if seenInAPI[user] {
continue
}
if running[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
continue
}
u := resolveChaturbateURL(m)
if u == "" {
continue
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
queued[user] = true
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItems(nextPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
if isAutostartPaused() {
@ -485,6 +511,10 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
pendingAutoStartMu.Lock()
_ = removeWatchedPendingAutoStartItem(it.userKey)
pendingAutoStartMu.Unlock()
if verboseLogs() {
fmt.Println("▶️ [autostart] started:", it.url)
}

View File

@ -671,12 +671,13 @@ type cbOnlineOutRoom struct {
}
type cbOnlineResponse struct {
Enabled bool `json:"enabled"`
FetchedAt time.Time `json:"fetchedAt"`
Count int `json:"count"`
Total int `json:"total"`
LastError string `json:"lastError"`
Rooms []cbOnlineOutRoom `json:"rooms"`
Enabled bool `json:"enabled"`
FetchedAt time.Time `json:"fetchedAt"` // letzter erfolgreicher Snapshot
LastAttempt time.Time `json:"lastAttempt"` // letzter Pull-Versuch
Count int `json:"count"`
Total int `json:"total"`
LastError string `json:"lastError"`
Rooms []cbOnlineOutRoom `json:"rooms"`
}
func hashKey(parts ...string) string {
@ -696,12 +697,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
enabled := getSettings().UseChaturbateAPI
// UA vom Client (oder fallback)
reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
if reqUA == "" {
reqUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
// ---------------------------
// Request params (GET/POST)
// ---------------------------
@ -837,25 +832,24 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes")
isHD = &b
}
allowedShow = toSet(shows)
}
onlySpecificUsers := len(users) > 0
// ---------------------------
// Response Cache (2s)
// Response Cache
// ---------------------------
cacheKey := "cb_online:" + hashKey(
fmt.Sprintf("enabled=%v", enabled),
"users="+strings.Join(users, ","),
"show="+strings.Join(keysOfSet(allowedShow), ","),
"gender="+strings.Join(keysOfSet(allowedGender), ","),
"country="+strings.Join(keysOfSet(allowedCountry), ","),
"tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","),
"minUsers="+derefInt(minUsers),
"isHD="+derefBool(isHD),
fmt.Sprintf("refresh=%v", wantRefresh),
"lite=1",
)
@ -874,12 +868,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
out := cbOnlineResponse{
Enabled: false,
FetchedAt: time.Time{},
Count: 0,
Total: 0,
LastError: "",
Rooms: []cbOnlineOutRoom{},
Enabled: false,
FetchedAt: time.Time{},
LastAttempt: time.Time{},
Count: 0,
Total: 0,
LastError: "",
Rooms: []cbOnlineOutRoom{},
}
body, _ := json.Marshal(out)
setCachedOnline(cacheKey, body)
@ -927,7 +922,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
}
cbRefreshMu.Unlock()
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
// Bootstrap darf weiterhin asynchron bleiben
// Bootstrap weiterhin asynchron
cbRefreshMu.Lock()
if cbRefreshInFlight {
cbRefreshMu.Unlock()
@ -954,7 +949,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
}
}
// ✅ JETZT erst den finalen Snapshot lesen
// finalen Snapshot lesen
cbMu.RLock()
fetchedAt = cb.FetchedAt
lastErr = cb.LastErr
@ -976,9 +971,8 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
}
// ---------------------------
// Rooms bauen (LITE, O(Anzahl requested Users))
// Rooms bauen (LITE)
// ---------------------------
matches := func(rm ChaturbateOnlineRoomLite) bool {
if len(allowedShow) > 0 {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
@ -1065,12 +1059,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
out := cbOnlineResponse{
Enabled: true,
FetchedAt: fetchedAt,
Count: len(outRooms),
Total: total,
LastError: lastErr,
Rooms: outRooms,
Enabled: true,
FetchedAt: fetchedAt,
LastAttempt: lastAttempt,
Count: len(outRooms),
Total: total,
LastError: lastErr,
Rooms: outRooms,
}
body, _ := json.Marshal(out)

View File

@ -21,6 +21,7 @@ type PendingAutoStartItem struct {
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry
CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown
ImageURL string `json:"imageUrl,omitempty"`
Source string `json:"source,omitempty"` // manual | watched
}
type PendingAutoStartResponse struct {
@ -33,6 +34,17 @@ type pendingAutoStartFile struct {
var pendingAutoStartMu sync.Mutex
const pendingAutoStartGlobalUserKey = "__global__"
func normalizePendingSourceServer(v string) string {
switch strings.TrimSpace(strings.ToLower(v)) {
case "watched":
return "watched"
default:
return "manual"
}
}
func normalizePendingModeServer(v string) string {
if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" {
return "probe_retry"
@ -103,12 +115,94 @@ func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
NextProbeAtMs: it.NextProbeAtMs,
CurrentShow: normalizePendingShowServer(it.CurrentShow),
ImageURL: strings.TrimSpace(it.ImageURL),
Source: normalizePendingSourceServer(it.Source),
})
}
return out, nil
}
func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
globalItems, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return nil, err
}
userItems, err := loadPendingAutoStartItems(userKey)
if err != nil {
return nil, err
}
byKey := make(map[string]PendingAutoStartItem)
for _, it := range globalItems {
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
if k == "" {
continue
}
byKey[k] = it
}
// user-spezifisch überschreibt global
for _, it := range userItems {
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
if k == "" {
continue
}
byKey[k] = it
}
out := make([]PendingAutoStartItem, 0, len(byKey))
for _, it := range byKey {
out = append(out, it)
}
return out, nil
}
func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error {
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey))
it.URL = strings.TrimSpace(it.URL)
it.Mode = normalizePendingModeServer(it.Mode)
it.CurrentShow = normalizePendingShowServer(it.CurrentShow)
it.ImageURL = strings.TrimSpace(it.ImageURL)
it.Source = "watched"
if it.ModelKey == "" || it.URL == "" {
continue
}
next = append(next, it)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func removeWatchedPendingAutoStartItem(modelKey string) error {
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if modelKey == "" {
return nil
}
items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey {
continue
}
next = append(next, it)
}
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error {
path := pendingAutoStartFilePath(userKey)
@ -148,7 +242,7 @@ func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc {
switch r.Method {
case http.MethodGet:
items, err := loadPendingAutoStartItems(userKey)
items, err := loadMergedPendingAutoStartItems(userKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -172,6 +266,7 @@ func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc {
in.Mode = normalizePendingModeServer(in.Mode)
in.CurrentShow = normalizePendingShowServer(in.CurrentShow)
in.ImageURL = strings.TrimSpace(in.ImageURL)
in.Source = normalizePendingSourceServer(in.Source)
if in.ModelKey == "" {
http.Error(w, "missing modelKey", http.StatusBadRequest)

View File

@ -2310,6 +2310,7 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d
"-movflags", "+faststart",
"-f", "mp4",
"-threads", "2",
tmp,
)

View File

@ -1473,6 +1473,7 @@ func recordStatus(w http.ResponseWriter, r *http.Request) {
jobsMu.RUnlock()
applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c)
respondJSON(w, &c)
}
@ -2300,7 +2301,13 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
}
c := *base
// Sprite-Infos aus Dateisystem/meta ergänzen
applyPreviewSpriteTruthToRecordJobMeta(&c)
// WICHTIG: completed-Flags für Meta/Thumb/Teaser/Sprites/Analyze setzen
applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c)
}

File diff suppressed because it is too large Load Diff

View File

@ -257,50 +257,37 @@ func runGenerateMissingAssets(ctx context.Context) {
}
// Initialisierung: Total etc. + SSE Push
updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(items)
st.Done = 0
st.GeneratedThumbs = 0
st.GeneratedPreviews = 0
st.Skipped = 0
st.Error = ""
})
type assetCandidate struct {
name string
path string
id string
beforeTruth finishedPhaseTruth
}
for i, it := range items {
candidates := make([]assetCandidate, 0, len(items))
skippedCount := 0
for _, it := range items {
if err := ctx.Err(); err != nil {
finishWithErr(err)
return
}
// aktuellen Dateinamen für UI setzen
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
})
// ID aus Dateiname
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
skippedCount++
continue
}
// Datei-Info (validieren)
vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Done = i + 1
})
skippedCount++
continue
}
// Vorher-Wahrheit ermitteln: was fehlt wirklich?
beforeTruth := finishedPhaseTruthForID(id)
needMeta := !beforeTruth.MetaReady
@ -309,17 +296,69 @@ func runGenerateMissingAssets(ctx context.Context) {
needSprites := !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady
// Wenn gar nichts fehlt -> alte evtl. Live-States wegräumen und skippen
// Bereits vollständig -> gar nicht erst in die Arbeitsliste aufnehmen
if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze {
clearAssetsTaskFinishedPostworkStates(it.name)
skippedCount++
continue
}
candidates = append(candidates, assetCandidate{
name: it.name,
path: it.path,
id: id,
beforeTruth: beforeTruth,
})
}
updateAssetsState(func(st *AssetsTaskState) {
st.Total = len(candidates)
st.Done = 0
st.GeneratedThumbs = 0
st.GeneratedPreviews = 0
st.Skipped = skippedCount
st.Error = ""
st.CurrentFile = ""
st.CurrentQueue = ""
st.CurrentPhase = ""
st.CurrentLabel = ""
})
if len(candidates) == 0 {
finishWithErr(nil)
return
}
for i, it := range candidates {
if err := ctx.Err(); err != nil {
finishWithErr(err)
return
}
updateAssetsState(func(st *AssetsTaskState) {
st.CurrentFile = it.name
})
id := it.id
vfi, verr := os.Stat(it.path)
if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 {
clearAssetsTaskFinishedPostworkStates(it.name)
updateAssetsState(func(st *AssetsTaskState) {
st.Skipped++
st.Done = i + 1
})
continue
}
beforeTruth := it.beforeTruth
needMeta := !beforeTruth.MetaReady
needThumb := !beforeTruth.ThumbReady
needTeaser := !beforeTruth.TeaserReady
needSprites := !beforeTruth.SpritesReady
needAnalyze := !beforeTruth.AnalyzeReady
// Pfade einmalig über zentralen Helper
_, _, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {

View File

@ -190,6 +190,7 @@ type ChaturbateOnlineRoomItem = {
type ChaturbateOnlineResponse = {
enabled?: boolean
fetchedAt?: string
lastAttempt?: string
count?: number
total?: number
lastError?: string
@ -204,6 +205,8 @@ type AutostartState = {
type PendingAutoStartMode = 'wait_public' | 'probe_retry'
type PendingAutoStartSource = 'manual' | 'watched'
type PendingAutoStartItem = {
modelKey: string
url: string
@ -211,6 +214,7 @@ type PendingAutoStartItem = {
nextProbeAtMs?: number
currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
source?: PendingAutoStartSource
}
type PendingAutoStartResponse = {
@ -393,6 +397,11 @@ function stripHotPrefix(name: string) {
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
function chaturbateModelKeyFromJob(job: RecordJob): string {
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
.trim()
.toLowerCase()
if (direct) return direct
const raw = String((job as any)?.sourceUrl ?? '').trim()
const norm0 = normalizeHttpUrl(raw)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
@ -400,7 +409,9 @@ function chaturbateModelKeyFromJob(job: RecordJob): string {
const keyFromUrl = providerKeyLowerFromUrl(norm)
if (keyFromUrl) return keyFromUrl
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
.trim()
.toLowerCase()
return keyFromFile
}
@ -418,6 +429,11 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
}
function modelEventKeyFromJob(job: RecordJob): string {
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
.trim()
.toLowerCase()
if (direct) return direct
const raw = String((job as any)?.sourceUrl ?? '').trim()
const norm0 = normalizeHttpUrl(raw)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
@ -425,7 +441,9 @@ function modelEventKeyFromJob(job: RecordJob): string {
const keyFromUrl = providerKeyLowerFromUrl(norm)
if (keyFromUrl) return keyFromUrl
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
.trim()
.toLowerCase()
return keyFromFile
}
@ -435,7 +453,9 @@ function modelEventKeyFromDoneJob(job: RecordJob): string {
}
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
.trim()
.toLowerCase()
let host = ''
try {
@ -449,6 +469,14 @@ function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: strin
// ignore
}
if (direct) {
return { modelKey: direct, host }
}
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
.trim()
.toLowerCase()
if (keyFromFile) {
return { modelKey: keyFromFile, host }
}
@ -583,6 +611,13 @@ export default function App() {
const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})
const [pendingAutoStartSourceByKey, setPendingAutoStartSourceByKey] = useState<Record<string, PendingAutoStartSource>>({})
const pendingAutoStartSourceByKeyRef = useRef(pendingAutoStartSourceByKey)
useEffect(() => {
pendingAutoStartSourceByKeyRef.current = pendingAutoStartSourceByKey
}, [pendingAutoStartSourceByKey])
const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey)
useEffect(() => {
pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey
@ -687,10 +722,12 @@ export default function App() {
setPendingAutoStartByKey({})
setPendingAutoStartModeByKey({})
setPendingBlindRetryAtByKey({})
setPendingAutoStartSourceByKey({})
pendingAutoStartByKeyRef.current = {}
pendingAutoStartModeByKeyRef.current = {}
pendingBlindRetryAtByKeyRef.current = {}
pendingAutoStartSourceByKeyRef.current = {}
// optional: URL-Feld leeren
setSourceUrl('')
@ -924,12 +961,15 @@ export default function App() {
const nextByKey: Record<string, string> = {}
const nextModeByKey: Record<string, PendingAutoStartMode> = {}
const nextBlindRetryAtByKey: Record<string, number> = {}
const nextSourceByKey: Record<string, PendingAutoStartSource> = {}
for (const item of items) {
const keyLower = String(item?.modelKey ?? '').trim().toLowerCase()
const norm0 = normalizeHttpUrl(String(item?.url ?? ''))
const mode: PendingAutoStartMode =
String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public'
const source: PendingAutoStartSource =
String(item?.source ?? '').trim() === 'watched' ? 'watched' : 'manual'
if (!keyLower || !norm0) continue
@ -937,6 +977,7 @@ export default function App() {
nextByKey[keyLower] = norm
nextModeByKey[keyLower] = mode
nextSourceByKey[keyLower] = source
if (mode === 'probe_retry') {
const ts = Number(item?.nextProbeAtMs ?? 0)
@ -953,6 +994,9 @@ export default function App() {
setPendingBlindRetryAtByKey(nextBlindRetryAtByKey)
pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey
setPendingAutoStartSourceByKey(nextSourceByKey)
pendingAutoStartSourceByKeyRef.current = nextSourceByKey
} catch {
// ignore
} finally {
@ -1072,6 +1116,8 @@ export default function App() {
return {
...(prevJob ?? ({} as RecordJob)),
id: jobId,
model: msg.model ?? (prevJob as any)?.model,
status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
sourceUrl: msg.sourceUrl ?? ((prevJob as any)?.sourceUrl ?? ''),
output: msg.output ?? (prevJob?.output ?? ''),
@ -1681,6 +1727,7 @@ export default function App() {
const alreadyRunning = jobsRef.current.some((j) => {
if (String(j.status || '').toLowerCase() !== 'running') return false
if ((j as any).endedAt) return false
const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
return jNorm === normUrl
@ -1697,7 +1744,9 @@ export default function App() {
}
if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
if (!silent) {
setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
}
return false
}
@ -1715,13 +1764,20 @@ export default function App() {
}),
})
if (created?.id && !opts?.hidden) {
startedToastByJobIdRef.current[String(created.id)] = true
// Wichtig: model lokal sofort mitschleppen, damit requiredModelKeys /
// overview / watched/fav/liked schon vor SSE korrekt funktionieren.
const localModelKey = providerKeyLowerFromUrl(normUrl)
const createdWithModel = localModelKey
? ({ ...created, model: localModelKey } as RecordJob)
: created
if ((createdWithModel as any)?.id && !opts?.hidden) {
startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true
}
if (!(opts?.hidden || Boolean((created as any)?.hidden))) {
if (!(opts?.hidden || Boolean((createdWithModel as any)?.hidden))) {
setJobs((prev) => {
const next = upsertJobById(prev, created)
const next = upsertJobById(prev, createdWithModel)
jobsRef.current = next
return next
})
@ -1833,11 +1889,17 @@ export default function App() {
[requiredModelKeys]
)
const lastOverviewSigRef = useRef<string>('')
const requiredModelKeysRef = useRef<string[]>(requiredModelKeys)
useEffect(() => {
requiredModelKeysRef.current = requiredModelKeys
}, [requiredModelKeys])
const lastOverviewSigRef = useRef<string | null>(null)
useEffect(() => {
if (!authed) return
const scheduleOverviewLoad = () => {
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
@ -1870,8 +1932,6 @@ export default function App() {
} catch {}
setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))
// counts/headerStats trotzdem später sauber nachziehen
scheduleOverviewLoad()
return
}
@ -1908,7 +1968,7 @@ export default function App() {
overviewTimerRef.current = null
}
}
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
}, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
const queuePendingAutoStart = useCallback((
keyLower: string,
@ -1918,6 +1978,7 @@ export default function App() {
opts?: {
mode?: PendingAutoStartMode
nextProbeAtMs?: number
source?: PendingAutoStartSource
}
) => {
const key = String(keyLower ?? '').trim().toLowerCase()
@ -1926,11 +1987,31 @@ export default function App() {
const norm = canonicalizeProviderUrl(norm0)
const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public'
const source: PendingAutoStartSource = opts?.source === 'watched' ? 'watched' : 'manual'
const nextProbeAtMs =
mode === 'probe_retry'
? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000))
: undefined
const nextShow = normalizePendingShow(show)
const nextRetryAt =
mode === 'probe_retry' && Number.isFinite(nextProbeAtMs)
? Number(nextProbeAtMs)
: 0
// ✅ ALTE Werte VOR dem State-Update lesen
const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
const prevMode = (pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public') as PendingAutoStartMode
const prevSource = (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource
const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
const sameUrl = prevUrl === norm
const sameMode = prevMode === mode
const sameSource = prevSource === source
const sameRetryAt =
mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [key]: norm }
pendingAutoStartByKeyRef.current = next
@ -1943,11 +2024,17 @@ export default function App() {
return next
})
setPendingAutoStartSourceByKey((prev) => {
const next = { ...(prev || {}), [key]: source }
pendingAutoStartSourceByKeyRef.current = next
return next
})
setPendingBlindRetryAtByKey((prev) => {
const next = { ...(prev || {}) }
if (mode === 'probe_retry') {
next[key] = Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : Date.now() + 60_000
next[key] = nextRetryAt || (Date.now() + 60_000)
} else {
delete next[key]
}
@ -1961,7 +2048,7 @@ export default function App() {
id: key,
modelKey: key,
url: norm,
currentShow: normalizePendingShow(show),
currentShow: nextShow,
imageUrl: imageUrl || undefined,
}
@ -1978,20 +2065,8 @@ export default function App() {
return [nextItem, ...prev]
})
const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
const prevMode = String(pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public').trim()
const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
const nextShow = normalizePendingShow(show)
const nextRetryAt = mode === 'probe_retry'
? (Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 0)
: 0
const sameUrl = prevUrl === norm
const sameMode = prevMode === mode
const sameRetryAt =
mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000
if (sameUrl && sameMode && sameRetryAt) {
// ✅ Nur persistieren, wenn sich wirklich etwas geändert hat
if (sameUrl && sameMode && sameSource && sameRetryAt) {
return
}
@ -2002,9 +2077,10 @@ export default function App() {
modelKey: key,
url: norm,
mode,
nextProbeAtMs: mode === 'probe_retry' ? nextProbeAtMs : undefined,
nextProbeAtMs: mode === 'probe_retry' ? nextRetryAt : undefined,
currentShow: nextShow,
imageUrl: imageUrl || undefined,
source,
}),
}).catch(() => {})
}, [])
@ -2016,7 +2092,8 @@ export default function App() {
const hadPending =
Boolean(pendingAutoStartByKeyRef.current[key]) ||
Boolean(pendingAutoStartModeByKeyRef.current[key]) ||
Boolean(pendingBlindRetryAtByKeyRef.current[key])
Boolean(pendingBlindRetryAtByKeyRef.current[key]) ||
Boolean(pendingAutoStartSourceByKeyRef.current[key])
if (!hadPending) return
@ -2041,6 +2118,13 @@ export default function App() {
return next
})
setPendingAutoStartSourceByKey((prev) => {
const next = { ...(prev || {}) }
delete next[key]
pendingAutoStartSourceByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) =>
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key)
)
@ -2170,7 +2254,6 @@ export default function App() {
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setCbOnlineFetchedAt('')
return
}
@ -2253,6 +2336,9 @@ export default function App() {
const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode
const source =
(pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource
if (snap.show === 'public') {
enqueueStart({
url,
@ -2272,6 +2358,7 @@ export default function App() {
) {
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
mode: 'wait_public',
source,
})
continue
}
@ -2287,6 +2374,7 @@ export default function App() {
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source,
})
}
@ -2373,18 +2461,28 @@ export default function App() {
useEffect(() => {
if (!authed || !recSettings.useChaturbateApi) {
setCbOnlineFetchedAt('')
setHeaderStats((prev) => ({
...prev,
onlineModelsCount: 0,
onlineWatchedModelsCount: 0,
onlineFavCount: 0,
onlineLikedCount: 0,
}))
return
}
let cancelled = false
let timer: number | null = null
let lastSeenFetchedAt = ''
const tick = async () => {
const tick = async (refresh = false) => {
try {
const onlineRes = await fetch('/api/chaturbate/online', {
method: 'GET',
cache: 'no-store',
})
const onlineRes = await fetch(
refresh ? '/api/chaturbate/online?refresh=1' : '/api/chaturbate/online',
{ method: 'GET', cache: 'no-store' }
)
if (!onlineRes.ok) return
@ -2392,33 +2490,54 @@ export default function App() {
if (cancelled || !data) return
const fetchedAt = String(data.fetchedAt ?? '').trim()
if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') {
setCbOnlineFetchedAt(fetchedAt)
const safeFetchedAt =
fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z' ? fetchedAt : ''
const totalRaw = Number(data.total ?? 0)
const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? totalRaw : 0
// Online-Gesamtcounter sofort aus der Online-API aktualisieren
setHeaderStats((prev) => {
if (prev.onlineModelsCount === total) return prev
return {
...prev,
onlineModelsCount: total,
}
})
setCbOnlineFetchedAt(safeFetchedAt)
// Derselbe erfolgreiche Snapshot triggert den Refresh
// der watched/fav/like Counter + modelsByKey
if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) {
lastSeenFetchedAt = safeFetchedAt
void loadAppModelsSnapshot(requiredModelKeysRef.current)
}
} catch {
// ignore
}
}
void tick()
void tick(true)
timer = window.setInterval(() => {
void tick()
void tick(false)
}, 10_000)
return () => {
cancelled = true
if (timer != null) window.clearInterval(timer)
}
}, [authed, recSettings.useChaturbateApi])
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot])
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
const norm0 = normalizeHttpUrl(rawUrl)
if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0)
const norm = canonicalizeProviderUrl(norm0)
const silent = Boolean(opts?.silent)
if (!silent) setError(null)
const provider = getProviderFromNormalizedUrl(norm)
@ -2430,7 +2549,9 @@ export default function App() {
const currentCookies = cookiesRef.current
if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
if (!silent) {
setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
}
return false
}
@ -2438,7 +2559,7 @@ export default function App() {
const alreadyRunning = jobsRef.current.some((j) => {
if (String(j.status || '').toLowerCase() !== 'running') return false
// Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
// Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
if ((j as any).endedAt) return false
const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
@ -2447,7 +2568,7 @@ export default function App() {
})
if (alreadyRunning) return true
// Chaturbate-Startlogik mit Online-API:
// Chaturbate-Startlogik mit Online-API:
// public -> direkt starten
// private/hidden/away -> wait_public
// offline/unknown -> probe_retry mit hidden start
@ -2472,7 +2593,10 @@ export default function App() {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: [mkLower] }),
body: JSON.stringify({
q: [mkLower],
refresh: true,
}),
})
if (onlineRes.ok) {
@ -2509,6 +2633,7 @@ export default function App() {
// 2) in API + nicht public => warten bis public
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
mode: 'wait_public',
source: 'manual',
})
return true
} else {
@ -2516,6 +2641,7 @@ export default function App() {
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source: 'manual',
})
const ok = await doStartNow(norm, silent, { hidden: true })
@ -2532,6 +2658,7 @@ export default function App() {
queuePendingAutoStart(mkLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source: 'manual',
})
const ok = await doStartNow(norm, silent, { hidden: true })
@ -2552,23 +2679,36 @@ export default function App() {
const created = await apiJSON<RecordJob>('/api/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: norm, cookie: cookieString }),
body: JSON.stringify({
url: norm,
cookie: cookieString,
}),
})
// ✅ verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
// Wichtig: model lokal sofort mitschleppen, damit overview / modelsByKey
// nicht erst nach SSE oder Dateinamen-Erkennung korrekt werden.
const localModelKey = providerKeyLowerFromUrl(norm)
const createdWithModel = localModelKey
? ({ ...created, model: localModelKey } as RecordJob)
: created
// verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
// und kurz danach kommt der Job nochmal über SSE/polling rein.
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
if ((createdWithModel as any)?.id) {
startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true
}
setJobs((prev) => {
const next = upsertJobById(prev, created)
const next = upsertJobById(prev, createdWithModel)
jobsRef.current = next
return next
})
return true
} catch (e: any) {
const msg = e?.message ?? String(e)
// Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
// Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
if (isCookieGateError(msg)) {
showMissingCookiesMessage({ silent })
return false

View File

@ -31,7 +31,7 @@ export default function LastUpdatedText({ fetchedAt, enabled = true }: Props) {
useEffect(() => {
if (!enabled) return
const t = window.setInterval(() => setNow(Date.now()), 10_000)
const t = window.setInterval(() => setNow(Date.now()), 1_000)
return () => window.clearInterval(t)
}, [enabled])

View File

@ -81,6 +81,35 @@ function regenerateTaskTitle(file: string) {
return short ? `Assets neu generieren · ${short}` : 'Assets neu generieren'
}
function assetsPhaseLabel(label?: string, phase?: string) {
const direct = String(label ?? '').trim()
if (direct) return direct
switch (String(phase ?? '').trim().toLowerCase()) {
case 'meta':
return 'Meta'
case 'thumb':
return 'Vorschaubild'
case 'teaser':
return 'Teaser'
case 'sprites':
return 'Sprites'
case 'analyze':
return 'KI-Analyse'
default:
return ''
}
}
function assetsTaskText(file?: string, label?: string, phase?: string) {
const fn = shortTaskFilename(file, 38)
const phaseText = assetsPhaseLabel(label, phase)
if (fn && phaseText) return `${fn} · ${phaseText}`
if (fn) return fn
return phaseText
}
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
@ -224,13 +253,16 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
if (!alive || !data) return
const assets = data.generateAssets
const assetsFile = String(assets?.currentFile || assets?.text || '').trim()
const assetsLabel = String(assets?.currentLabel || '').trim()
const assetsPhase = String(assets?.currentPhase || '').trim()
if (assets?.running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
text: assetsTaskText(assetsFile, assetsLabel, assetsPhase),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
@ -246,7 +278,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
assetsError.toLowerCase() === 'canceled'
setAssetsTask((t) => {
// Wenn schon idle oder bereits am Ausfaden: nicht wieder "sichtbar reaktivieren"
if (cancelled && (t.status === 'idle' || t.fading)) {
return t
}
@ -255,7 +286,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
...t,
status: cancelled ? 'cancelled' : 'error',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
text: assetsTaskText(assetsFile, assetsLabel, assetsPhase),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: cancelled ? undefined : assetsError,
@ -270,25 +301,13 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
...t,
status: 'done',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
text: assetsTaskText(assetsFile, assetsLabel, assetsPhase),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
}))
}
const regenJobs = Array.isArray(data?.regenerateAssetsJobs)
@ -870,7 +889,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="mt-3 space-y-3">
<Task
title="Assets-Generator"
description="Erzeugt fehlende Assets (thumb/preview/meta). Fortschritt & Abbrechen oben in der Taskliste."
description="Erzeugt nur fehlende oder unvollständige Assets (Meta, Vorschaubild, Teaser, Sprites, KI-Analyse). Bereits vorhandene Assets werden übersprungen."
startLabel="Start"
startingLabel="Starte…"
startUrl="/api/tasks/generate-assets"
@ -891,13 +910,15 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}))
}}
onProgress={(p) => {
const fn = shortTaskFilename(p.currentFile)
const currentFile = String((p as any).currentFile ?? '').trim()
const currentLabel = String((p as any).currentLabel ?? '').trim()
const currentPhase = String((p as any).currentPhase ?? '').trim()
setAssetsTask((t: TaskItem) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: fn || '',
text: assetsTaskText(currentFile, currentLabel, currentPhase),
done: p.done,
total: p.total,
}))