nsfwapp/backend/chaturbate_autostart.go
2026-05-11 19:13:54 +02:00

1058 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\chaturbate_autostart.go
package main
import (
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
blind bool
source string // "watched" | "manual" | "resume"
force bool
ownerUserKey string // nur für manuell hinzugefügte Pending-Einträge
}
var lastChaturbateDownloadLimitLogAt time.Time
func logChaturbateAutoStartError(prefix string, modelURL string, err error) {
if err == nil {
return
}
// Download-Limit nur selten loggen, weil der Autostart-Worker regelmäßig retryt.
if isDownloadLimitReachedError(err) {
now := time.Now()
if !lastChaturbateDownloadLimitLogAt.IsZero() &&
now.Sub(lastChaturbateDownloadLimitLogAt) < 60*time.Second {
return
}
lastChaturbateDownloadLimitLogAt = now
appLogln(prefix, modelURL, err)
return
}
appLogln(prefix, modelURL, err)
}
func normUser(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func watchedChaturbateUserKey(m WatchedModelLite) string {
key := normUser(m.ModelKey)
if key != "" {
return key
}
in := strings.TrimSpace(m.Input)
if in == "" {
return ""
}
// zuerst echte URL parsen
if u := chaturbateUserFromURL(in); u != "" {
return u
}
// fallback: roher input könnte schon nur der username sein
return normUser(strings.Trim(in, "/"))
}
func chaturbateUserFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lower := strings.ToLower(raw)
if !strings.Contains(lower, "://") {
if strings.HasPrefix(lower, "chaturbate.com/") || strings.HasPrefix(lower, "www.chaturbate.com/") {
raw = "https://" + raw
} else {
return ""
}
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
host = strings.TrimPrefix(host, "www.")
if host != "chaturbate.com" && !strings.HasSuffix(host, ".chaturbate.com") {
return ""
}
parts := strings.Split(u.Path, "/")
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
return normUser(p)
}
}
return ""
}
func cookieHeaderFromSettings(s RecorderSettings) string {
m, err := decryptCookieMap(s.EncryptedCookies)
if err != nil || len(m) == 0 {
return ""
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
first := true
for _, k := range keys {
v := strings.TrimSpace(m[k])
k = strings.TrimSpace(k)
if k == "" || v == "" {
continue
}
if !first {
b.WriteString("; ")
}
first = false
b.WriteString(k)
b.WriteString("=")
b.WriteString(v)
}
return b.String()
}
func resolveChaturbateURL(m WatchedModelLite) string {
if u := chaturbateUserFromURL(m.Input); u != "" {
return fmt.Sprintf("https://chaturbate.com/%s/", u)
}
key := watchedChaturbateUserKey(m)
if key == "" {
return ""
}
return fmt.Sprintf("https://chaturbate.com/%s/", key)
}
func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func(), onNoOutput func()) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
jobsMu.RLock()
job := jobs[jobID]
status := JobStatus("")
out := ""
hidden := false
if job != nil {
status = job.Status
out = strings.TrimSpace(job.Output)
hidden = job.Hidden
}
jobsMu.RUnlock()
// echte Output-Datei vorhanden?
// Wichtig: zuerst prüfen, bevor wir bei "nicht mehr running" zurückkehren.
// Ein Job kann sehr schnell in Postwork/Stopped/Failed wechseln.
if job != nil && out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
// hidden blind-try wird jetzt sichtbar gemacht
if hidden {
jobsMu.Lock()
j := jobs[jobID]
if j != nil {
j.Hidden = false
}
jobsMu.Unlock()
}
if onOutput != nil {
onOutput()
}
publishJob(jobID)
return
}
}
// Job schon weg oder nicht mehr running, aber keine Datei entstanden.
// Für resume ist das wichtig: Der Pending-Eintrag wurde beim Start entfernt
// und muss bei Start ohne Output wiederhergestellt werden.
if job == nil || status != JobRunning {
if onNoOutput != nil {
onNoOutput()
}
return
}
time.Sleep(900 * time.Millisecond)
}
// nach Wartezeit immer noch keine Datei => stoppen + löschen
jobsMu.Lock()
job := jobs[jobID]
if job == nil || job.Status != JobRunning {
jobsMu.Unlock()
return
}
pc := job.previewCmd
job.previewCmd = nil
previewCancel := job.previewCancel
job.previewCancel = nil
recordCancel := job.cancel
out := strings.TrimSpace(job.Output)
wasVisible := !job.Hidden
jobsMu.Unlock()
if previewCancel != nil {
previewCancel()
}
if pc != nil && pc.Process != nil {
_ = pc.Process.Kill()
}
if recordCancel != nil {
recordCancel()
}
// 0-Byte Datei ggf. löschen
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
_ = os.Remove(out)
}
}
if onNoOutput != nil {
onNoOutput()
}
jobsMu.Lock()
j := jobs[jobID]
delete(jobs, jobID)
jobsMu.Unlock()
if wasVisible && j != nil {
publishJobRemove(j)
}
}
// Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil {
if verboseLogs() {
appLogln("⚠️ [autostart] model store is nil")
}
return
}
const scanInterval = 1 * time.Second
const startInterval = 5 * time.Second
const snapshotMaxAge = 90 * time.Second
// normaler Retry für API-public
const retryCooldown = 25 * time.Second
// aggressiver vermeiden für "nicht in API, aber watched" Blind-Try
const blindRetryCooldown = 2 * time.Minute
// wie lange wir warten, ob eine echte Datei entsteht
const outputProbeMax = 20 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastTry := map[string]time.Time{}
lastBlindTry := map[string]time.Time{}
probeCursor := 0
scanTicker := time.NewTicker(scanInterval)
startTicker := time.NewTicker(startInterval)
defer scanTicker.Stop()
defer startTicker.Stop()
var lastCookieHdr string
for {
select {
case <-scanTicker.C:
autostartPaused := isAutostartPaused()
s := getSettings()
if !s.UseChaturbateAPI {
queue = queue[:0]
queued = map[string]bool{}
lastCookieHdr = ""
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nil)
pendingAutoStartMu.Unlock()
continue
}
cookieHdr := cookieHeaderFromSettings(s)
if !hasChaturbateCookies(cookieHdr) {
lastCookieHdr = ""
continue
}
lastCookieHdr = cookieHdr
rooms, fetchedAt, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge)
if !fresh {
if verboseLogs() {
if fetchedAt.IsZero() {
appLogln("⚠️ [autostart] chaturbate snapshot missing")
} else {
appLogln("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second))
}
}
continue
}
showByUser := map[string]string{}
imageByUser := map[string]string{}
for _, r := range rooms {
u := normUser(r.Username)
if u == "" {
continue
}
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
imageByUser[u] = selectBestRoomImageURL(r)
}
pendingAutoStartMu.Lock()
manualItems, err := loadManualPendingAutoStartItemsForProvider("chaturbate")
pendingAutoStartMu.Unlock()
if err != nil {
if verboseLogs() {
appLogln("⚠️ [autostart] load manual chaturbate pending failed:", err)
}
continue
}
pendingAutoStartMu.Lock()
resumeItems, err := loadResumePendingAutoStartItemsForProvider("chaturbate")
pendingAutoStartMu.Unlock()
if err != nil {
if verboseLogs() {
appLogln("⚠️ [autostart] load resume chaturbate pending failed:", err)
}
continue
}
manualByUser := map[string]PendingAutoStartItem{}
manualOrder := make([]string, 0, len(manualItems))
for _, it := range manualItems {
key := normUser(it.ModelKey)
if key == "" {
key = chaturbateUserFromURL(it.URL)
}
if key == "" {
continue
}
it.ModelKey = key
it.URL = strings.TrimSpace(it.URL)
if it.URL == "" {
it.URL = fmt.Sprintf("https://chaturbate.com/%s/", key)
}
if _, exists := manualByUser[key]; exists {
continue
}
manualByUser[key] = it
manualOrder = append(manualOrder, key)
}
resumeByUser := map[string]PendingAutoStartItem{}
resumeOrder := make([]string, 0, len(resumeItems))
for _, it := range resumeItems {
key := normUser(it.ModelKey)
if key == "" {
key = chaturbateUserFromURL(it.URL)
}
if key == "" {
continue
}
it.ModelKey = key
it.URL = strings.TrimSpace(it.URL)
if it.URL == "" {
it.URL = fmt.Sprintf("https://chaturbate.com/%s/", key)
}
if _, exists := resumeByUser[key]; exists {
continue
}
resumeByUser[key] = it
resumeOrder = append(resumeOrder, key)
}
// laufende Jobs sammeln
runningByUser := map[string]*RecordJob{}
hiddenProbeUser := ""
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 == "" {
continue
}
runningByUser[u] = j
if j.Hidden && hiddenProbeUser == "" {
hiddenProbeUser = u
}
}
jobsMu.RUnlock()
// watched list aus DB
// bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben
watched := store.ListWatchedLite("")
watchedByUser := map[string]WatchedModelLite{}
watchedOrder := make([]string, 0, len(watched))
for _, m := range watched {
if !m.Watching {
continue
}
host := strings.ToLower(strings.TrimSpace(m.Host))
host = strings.TrimPrefix(host, "www.")
if host != "" && host != "chaturbate.com" {
continue
}
key := watchedChaturbateUserKey(m)
if key == "" {
continue
}
if host == "" {
in := strings.TrimSpace(m.Input)
if in != "" {
lowerIn := strings.ToLower(in)
if strings.Contains(lowerIn, "http://") || strings.Contains(lowerIn, "https://") {
if chaturbateUserFromURL(in) == "" {
continue
}
}
}
}
if _, exists := watchedByUser[key]; exists {
continue
}
watchedByUser[key] = m
watchedOrder = append(watchedOrder, key)
}
// queue prune
nextQueue := queue[:0]
nextQueued := map[string]bool{}
for _, it := range queue {
if runningByUser[it.userKey] != nil {
continue
}
switch it.source {
case "manual":
m, ok := manualByUser[it.userKey]
if !ok {
continue
}
it.url = strings.TrimSpace(m.URL)
if it.url == "" {
continue
}
case "resume":
m, ok := resumeByUser[it.userKey]
if !ok {
continue
}
it.url = strings.TrimSpace(m.URL)
if it.url == "" {
continue
}
it.force = true
default:
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
it.url = resolveChaturbateURL(m)
if it.url == "" {
continue
}
}
show := normalizePendingShowServer(showByUser[it.userKey])
if it.blind {
// Offline-/Unknown-Blind-Probes nur für manuell hinzugefügte Models.
// Watched/Resume dürfen bei offline nicht in "Wartend" bleiben.
if it.source != "manual" {
continue
}
if show != "offline" && show != "unknown" {
continue
}
} else {
if show != "public" {
continue
}
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
blindQueued := false
for _, it := range queue {
if it.blind {
blindQueued = true
break
}
}
offlineCandidates := make([]autoStartItem, 0, len(watchedOrder))
nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
now := time.Now()
resumePendingThisScan := map[string]bool{}
// Sichtbare laufende Downloads bei private/hidden/away stoppen
// und als "resume" merken. Diese Resume-Einträge starten später
// unabhängig von Autostart-Pause und unabhängig vom Download-Limit.
for user, runningJob := range runningByUser {
if runningJob == nil {
continue
}
if runningJob.Hidden {
continue
}
show := normalizePendingShowServer(showByUser[user])
if show != "private" && show != "hidden" && show != "away" {
continue
}
u := strings.TrimSpace(runningJob.SourceURL)
if u == "" {
u = fmt.Sprintf("https://chaturbate.com/%s/", user)
}
img := strings.TrimSpace(imageByUser[user])
// Wichtig:
// Resume NICHT in nextPending speichern.
// nextPending wird später über saveWatchedPendingAutoStartItemsForProvider()
// gespeichert, und diese Funktion setzt Source hart auf "watched".
// Deshalb Resume separat in der globalen Pending-Datei speichern.
pendingAutoStartMu.Lock()
if err := saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
}); err != nil && verboseLogs() {
appLogln("⚠️ [autostart] save resume pending failed:", user, err)
}
pendingAutoStartMu.Unlock()
resumePendingThisScan[user] = true
if verboseLogs() {
appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show)
}
stopJobsInternal([]*RecordJob{runningJob})
}
// Resume hat Vorrang und ignoriert Autostart-Pause.
for _, user := range resumeOrder {
itm := resumeByUser[user]
u := strings.TrimSpace(itm.URL)
if u == "" {
u = fmt.Sprintf("https://chaturbate.com/%s/", user)
}
show := normalizePendingShowServer(showByUser[user])
img := strings.TrimSpace(imageByUser[user])
switch show {
case "public":
if runningByUser[user] != nil {
continue
}
if queued[user] {
continue
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
blind: false,
source: "resume",
force: true,
})
queued[user] = true
case "private", "hidden", "away":
if resumePendingThisScan[user] {
continue
}
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
case "offline":
pendingAutoStartMu.Lock()
_ = removeResumePendingAutoStartItemForProvider("chaturbate", user)
pendingAutoStartMu.Unlock()
case "unknown":
if resumePendingThisScan[user] {
continue
}
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
}
for _, user := range watchedOrder {
m := watchedByUser[user]
u := resolveChaturbateURL(m)
if u == "" {
continue
}
show := normalizePendingShowServer(showByUser[user])
img := strings.TrimSpace(imageByUser[user])
switch show {
case "public":
if autostartPaused {
continue
}
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,
blind: false,
source: "watched",
})
queued[user] = true
case "private", "hidden", "away":
// Wenn der User in diesem Scan gerade wegen private/hidden/away
// aus einem laufenden Download gestoppt wurde, existiert bereits
// ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag
// zusätzlich speichern.
if resumePendingThisScan[user] {
continue
}
nextWatchedPending = append(nextWatchedPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
case "offline":
// Watched-Model ist wirklich offline:
// nicht als "Wartend" anzeigen und keinen Blind-Probe starten.
continue
default:
// unknown nicht aggressiv in Wartend schieben.
// Wenn kein frischer Online-Status da ist, lieber nichts tun.
continue
}
}
for _, user := range manualOrder {
if _, exists := watchedByUser[user]; exists {
// watched gewinnt
continue
}
itm := manualByUser[user]
u := strings.TrimSpace(itm.URL)
if u == "" {
continue
}
show := normalizePendingShowServer(showByUser[user])
switch show {
case "public":
if autostartPaused {
continue
}
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,
blind: false,
source: "manual",
ownerUserKey: itm.OwnerUserKey,
})
queued[user] = true
case "private", "hidden", "away":
// manual pending bleibt einfach stehen und wartet auf public
continue
default:
if autostartPaused {
continue
}
if runningByUser[user] != nil {
continue
}
if queued[user] {
continue
}
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
continue
}
offlineCandidates = append(offlineCandidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "manual",
ownerUserKey: itm.OwnerUserKey,
})
}
}
// Nur EIN Offline-Kandidat gleichzeitig in die Queue.
// Bei pausiertem Autostart werden oben keine normalen Offline-Kandidaten erzeugt.
if !blindQueued && !hasHiddenProbeRunningForProvider("chaturbate") && len(offlineCandidates) > 0 {
n := len(offlineCandidates)
start := 0
if probeCursor > 0 {
start = probeCursor % n
}
for i := 0; i < n; i++ {
idx := (start + i) % n
it := offlineCandidates[idx]
if queued[it.userKey] {
continue
}
if runningByUser[it.userKey] != nil {
continue
}
queue = append(queue, it)
queued[it.userKey] = true
probeCursor = (idx + 1) % n
break
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
s := getSettings()
if !s.UseChaturbateAPI {
continue
}
if !hasChaturbateCookies(lastCookieHdr) {
continue
}
if len(queue) == 0 {
continue
}
rooms, _, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge)
if !fresh {
continue
}
showByUser := map[string]string{}
for _, r := range rooms {
u := normUser(r.Username)
if u == "" {
continue
}
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
}
paused := isAutostartPaused()
startIdx := -1
for i, candidate := range queue {
if paused && !candidate.force {
continue
}
startIdx = i
break
}
if startIdx < 0 {
continue
}
it := queue[startIdx]
queue = append(queue[:startIdx], queue[startIdx+1:]...)
delete(queued, it.userKey)
show := normalizePendingShowServer(showByUser[it.userKey])
isPublic := show == "public"
// Normale queued Starts dürfen NICHT als Blind-Probe gestartet werden.
// Wenn ein resume/watched/manual Eintrag inzwischen nicht mehr public ist,
// bleibt er wartend bzw. wird im nächsten Scan korrekt neu bewertet.
if !it.blind && !isPublic {
if it.source == "resume" {
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: show,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
continue
}
// Nicht-public nur einzeln nacheinander prüfen.
if it.blind && hasHiddenProbeRunningForProvider("chaturbate") {
continue
}
if isJobRunningForURL(it.url) {
continue
}
if !it.blind {
lastTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: it.url,
Cookie: lastCookieHdr,
IgnoreConcurrentLimit: it.force,
})
if err != nil {
if verboseLogs() {
logChaturbateAutoStartError("❌ [autostart] start failed:", it.url, err)
}
continue
}
if verboseLogs() {
if it.source == "resume" {
appLogln("▶️ [autostart] resumed:", it.url)
} else {
appLogln("▶️ [autostart] started:", it.url)
}
}
if job != nil {
if it.source == "resume" {
pendingAutoStartMu.Lock()
_ = removeResumePendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, func() {
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: "unknown",
Source: "resume",
})
pendingAutoStartMu.Unlock()
})
continue
}
if it.source == "manual" {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
}, nil)
} else {
pendingAutoStartMu.Lock()
_ = removeWatchedPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil)
}
}
} else {
if paused && !it.force {
continue
}
lastBlindTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: it.url,
Cookie: lastCookieHdr,
Hidden: true,
IgnoreConcurrentLimit: it.force,
})
if err != nil || job == nil {
if verboseLogs() {
if err != nil {
logChaturbateAutoStartError("❌ [autostart] blind start failed:", it.url, err)
} else {
appLogln("❌ [autostart] blind start failed:", it.url, "job is nil")
}
}
continue
}
if verboseLogs() {
appLogln("▶️ [autostart] blind try started:", it.url)
}
if it.source == "manual" {
go chaturbateAbortIfNoOutput(
job.ID,
outputProbeMax,
func() {
// Download läuft wirklich: manuellen Wartend-Eintrag entfernen.
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
},
func() {
// Blind-Probe hat keine Datei erzeugt:
// Jetzt erst als "Wartend" speichern.
owner := strings.TrimSpace(it.ownerUserKey)
if owner == "" {
return
}
pendingAutoStartMu.Lock()
_ = saveManualPendingAutoStartItemForUser(owner, PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: "offline",
Source: "manual",
})
pendingAutoStartMu.Unlock()
},
)
} else {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil)
}
}
}
}
}