fixed bugs & UI improvements

This commit is contained in:
Linrador 2026-04-09 12:48:17 +02:00
parent e5f506ec45
commit e25aca59c3
19 changed files with 1586 additions and 559 deletions

View File

@ -131,20 +131,27 @@ func generatePreviewSpriteJPG(
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := ctx.Err(); err != nil {
return err
}
err := cmd.Run()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
msgErr := strings.TrimSpace(stderr.String())
if msgErr == "" {
msgErr = "(kein stderr)"
}
return fmt.Errorf(
"ffmpeg sprite failed for %q -> %q: %w | ctxErr=%v | args=%q | stderr=%s",
"ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s",
videoPath,
tmpPath,
err,
ctx.Err(),
strings.Join(cmd.Args, " "),
msgErr,
)

View File

@ -3,9 +3,11 @@
package main
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
@ -14,6 +16,8 @@ import (
type autoStartItem struct {
userKey string
url string
blind bool
source string // "watched" | "manual"
}
func normUser(s string) string {
@ -45,14 +49,27 @@ func chaturbateUserFromURL(raw string) string {
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(u.Hostname())
if !strings.Contains(host, "chaturbate.com") {
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)
@ -60,6 +77,7 @@ func chaturbateUserFromURL(raw string) string {
return normUser(p)
}
}
return ""
}
@ -99,15 +117,11 @@ func cookieHeaderFromSettings(s RecorderSettings) string {
}
func resolveChaturbateURL(m WatchedModelLite) string {
in := strings.TrimSpace(m.Input)
if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
return in
if u := chaturbateUserFromURL(m.Input); u != "" {
return fmt.Sprintf("https://chaturbate.com/%s/", u)
}
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
if key == "" {
key = watchedChaturbateUserKey(m)
}
key := watchedChaturbateUserKey(m)
if key == "" {
return ""
}
@ -115,7 +129,7 @@ func resolveChaturbateURL(m WatchedModelLite) string {
return fmt.Sprintf("https://chaturbate.com/%s/", key)
}
func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func(), onNoOutput func()) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
@ -150,6 +164,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
jobsMu.Unlock()
}
if onOutput != nil {
onOutput()
}
publishJob(jobID)
return
}
@ -195,6 +213,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
}
}
if onNoOutput != nil {
onNoOutput()
}
jobsMu.Lock()
j := jobs[jobID]
delete(jobs, jobID)
@ -205,6 +227,41 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
}
}
func clearAllPendingAutoStartOnStartup() error {
pendingAutoStartMu.Lock()
defer pendingAutoStartMu.Unlock()
dir := filepath.Join("data", "pending-autostart")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name == "" {
continue
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
// Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil {
@ -232,6 +289,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued := map[string]bool{}
lastTry := map[string]time.Time{}
lastBlindTry := map[string]time.Time{}
probeCursor := 0
scanTicker := time.NewTicker(scanInterval)
startTicker := time.NewTicker(startInterval)
@ -252,6 +310,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queue = queue[:0]
queued = map[string]bool{}
lastCookieHdr = ""
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nil)
pendingAutoStartMu.Unlock()
continue
}
@ -276,20 +339,56 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
showByUser := map[string]string{}
imageByUser := map[string]string{}
seenInAPI := map[string]bool{}
pendingAutoStartMu.Lock()
manualItems, err := loadManualPendingAutoStartItemsForProvider("chaturbate")
pendingAutoStartMu.Unlock()
if err != nil {
if verboseLogs() {
fmt.Println("⚠️ [autostart] load manual 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)
}
for _, r := range rooms {
u := normUser(r.Username)
if u == "" {
continue
}
seenInAPI[u] = true
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
imageByUser[u] = selectBestRoomImageURL(r)
}
// laufende Jobs sammeln
runningByUser := map[string]*RecordJob{}
hiddenProbeUser := ""
jobsMu.RLock()
for _, j := range jobs {
if j == nil {
@ -301,9 +400,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
continue
}
u := chaturbateUserFromURL(j.SourceURL)
if u != "" {
runningByUser[u] = j
if u == "" {
continue
}
runningByUser[u] = j
if j.Hidden && hiddenProbeUser == "" {
hiddenProbeUser = u
}
}
jobsMu.RUnlock()
@ -311,28 +417,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
// 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
}
// nur Chaturbate-Einträge berücksichtigen
host := strings.ToLower(strings.TrimSpace(m.Host))
host = strings.TrimPrefix(host, "www.")
if host != "" && host != "chaturbate.com" {
// Falls Host gesetzt und nicht chaturbate -> ignorieren
continue
}
// Falls Host leer ist, versuchen wir über Input/ModelKey zu erkennen
key := watchedChaturbateUserKey(m)
if key == "" {
continue
}
// Wenn Host leer ist, aber Input eindeutig nicht Chaturbate ist -> skip
if host == "" {
in := strings.TrimSpace(m.Input)
if in != "" {
@ -345,36 +450,84 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
}
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 {
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
if runningByUser[it.userKey] != nil {
continue
}
it.url = resolveChaturbateURL(m)
if it.url == "" {
continue
if it.source == "manual" {
m, ok := manualByUser[it.userKey]
if !ok {
continue
}
it.url = strings.TrimSpace(m.URL)
if it.url == "" {
continue
}
} else {
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
it.url = resolveChaturbateURL(m)
if it.url == "" {
continue
}
}
show := normalizePendingShowServer(showByUser[it.userKey])
if it.blind {
if show != "offline" && show != "unknown" {
continue
}
} else {
if show != "public" {
continue
}
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
now := time.Now()
nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser))
blindQueued := false
selectedBlindUser := hiddenProbeUser
for _, it := range queue {
if it.blind {
blindQueued = true
if selectedBlindUser == "" {
selectedBlindUser = it.userKey
}
break
}
}
offlineCandidates := make([]autoStartItem, 0, len(watchedOrder))
nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
now := time.Now()
for _, user := range watchedOrder {
m := watchedByUser[user]
for user, m := range watchedByUser {
u := resolveChaturbateURL(m)
if u == "" {
continue
@ -385,7 +538,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
switch show {
case "public":
// public => kein Pending-Eintrag
if runningByUser[user] != nil {
continue
}
@ -399,11 +551,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queue = append(queue, autoStartItem{
userKey: user,
url: u,
blind: false,
source: "watched",
})
queued[user] = true
case "private", "hidden", "away":
// laufenden watched-Download beenden und auf public warten
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
@ -418,19 +571,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
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
}
@ -441,16 +581,127 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
offlineCandidates = append(offlineCandidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "watched",
})
}
}
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 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",
})
queued[user] = true
case "private", "hidden", "away":
// manual pending bleibt einfach stehen und wartet auf public
continue
default:
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",
})
}
}
// Nur EIN Offline-Kandidat gleichzeitig in die Queue
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
selectedBlindUser = it.userKey
probeCursor = (idx + 1) % n
break
}
}
if selectedBlindUser != "" {
if m, ok := watchedByUser[selectedBlindUser]; ok {
u := resolveChaturbateURL(m)
if u != "" {
show := normalizePendingShowServer(showByUser[selectedBlindUser])
img := strings.TrimSpace(imageByUser[selectedBlindUser])
nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli()
if t, ok := lastBlindTry[selectedBlindUser]; ok {
nextProbeAtMs = t.Add(blindRetryCooldown).UnixMilli()
}
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: selectedBlindUser,
URL: u,
Mode: "probe_retry",
NextProbeAtMs: nextProbeAtMs,
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
}
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItems(nextPending)
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
@ -471,15 +722,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
it := queue[0]
queue = queue[1:]
delete(queued, it.userKey)
// prüfen, ob inzwischen schon aktiv
if isJobRunningForURL(it.url) {
continue
}
rooms, _, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge)
if !fresh {
continue
@ -494,10 +736,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
}
it := queue[0]
show := strings.TrimSpace(showByUser[it.userKey])
isPublic := strings.Contains(show, "public")
if isPublic {
// Nicht-public nur einzeln nacheinander prüfen
if it.blind && hasHiddenProbeRunningForProvider("chaturbate") {
continue
}
queue = queue[1:]
delete(queued, it.userKey)
if isJobRunningForURL(it.url) {
continue
}
if !it.blind && isPublic {
lastTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
@ -511,16 +766,24 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
pendingAutoStartMu.Lock()
_ = removeWatchedPendingAutoStartItem(it.userKey)
pendingAutoStartMu.Unlock()
if verboseLogs() {
fmt.Println("▶️ [autostart] started:", it.url)
}
if job != nil {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
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 {
lastBlindTry[it.userKey] = time.Now()
@ -541,7 +804,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
fmt.Println("▶️ [autostart] blind try started:", it.url)
}
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
if it.source == "manual" {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
}, nil)
} else {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil)
}
}
}
}

View File

@ -3,11 +3,92 @@
package main
import (
"fmt"
"net/url"
"os"
"strings"
"time"
)
func mfcUserFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lower := strings.ToLower(raw)
if !strings.Contains(lower, "://") {
if strings.HasPrefix(lower, "myfreecams.com/") || strings.HasPrefix(lower, "www.myfreecams.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 != "myfreecams.com" && !strings.HasSuffix(host, ".myfreecams.com") {
return ""
}
hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/")
if hash != "" {
parts := strings.Split(hash, "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
return strings.ToLower(p)
}
}
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
return strings.ToLower(p)
}
}
return ""
}
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
key := strings.ToLower(strings.TrimSpace(m.ModelKey))
if key != "" {
return key
}
in := strings.TrimSpace(m.Input)
if in == "" {
return ""
}
if u := mfcUserFromURL(in); u != "" {
return u
}
return strings.ToLower(strings.Trim(in, "/"))
}
func resolveMyFreeCamsURL(m WatchedModelLite) string {
if u := mfcUserFromURL(m.Input); u != "" {
return fmt.Sprintf("https://www.myfreecams.com/#%s", u)
}
key := watchedMyFreeCamsUserKey(m)
if key == "" {
return ""
}
return fmt.Sprintf("https://www.myfreecams.com/#%s", key)
}
// Startet watched MyFreeCams Models (ohne API) "best-effort".
// Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
@ -15,85 +96,344 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
return
}
// pro Model: Retry-Cooldown, damit nicht ständig dieselben Models angestoßen werden
const scanInterval = 1 * time.Second
const startInterval = 2 * time.Second
const cooldown = 2 * time.Minute
// wie lange wir nach Start warten, ob eine Output-Datei entsteht
const outputProbeMax = 20 * time.Second
// kleine Pause zwischen Starts, damit nicht zu viele Jobs auf einmal anlaufen
const startGap = 1200 * time.Millisecond
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastAttempt := map[string]time.Time{}
probeCursor := 0
tick := time.NewTicker(6 * time.Second)
defer tick.Stop()
scanTicker := time.NewTicker(scanInterval)
startTicker := time.NewTicker(startInterval)
defer scanTicker.Stop()
defer startTicker.Stop()
for range tick.C {
// global früh abbrechen
if isAutostartPaused() {
continue
}
s := getSettings()
if !s.UseMyFreeCamsWatcher {
continue
}
// watched Models aus DB
watched := store.ListWatchedLite("myfreecams.com")
if len(watched) == 0 {
continue
}
// langsam nacheinander starten (keine API -> einzelnes "Anprobieren")
for _, m := range watched {
// während des Loops erneut prüfen, falls User live stoppt
for {
select {
case <-scanTicker.C:
if isAutostartPaused() {
break
continue
}
s := getSettings()
if !s.UseMyFreeCamsWatcher {
queue = queue[:0]
queued = map[string]bool{}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil)
pendingAutoStartMu.Unlock()
continue
}
watched := store.ListWatchedLite("myfreecams.com")
pendingAutoStartMu.Lock()
manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc")
pendingAutoStartMu.Unlock()
if err != nil {
fmt.Println("⚠️ [mfc autostart] load manual pending failed:", err)
continue
}
if len(watched) == 0 && len(manualItems) == 0 {
queue = queue[:0]
queued = map[string]bool{}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil)
pendingAutoStartMu.Unlock()
continue
}
watchedByUser := map[string]WatchedModelLite{}
watchedOrder := make([]string, 0, len(watched))
manualByUser := map[string]PendingAutoStartItem{}
manualOrder := make([]string, 0, len(manualItems))
for _, it := range manualItems {
key := strings.ToLower(strings.TrimSpace(it.ModelKey))
if key == "" {
key = mfcUserFromURL(it.URL)
}
if key == "" {
continue
}
it.ModelKey = key
it.URL = strings.TrimSpace(it.URL)
if it.URL == "" {
continue
}
if _, exists := manualByUser[key]; exists {
continue
}
manualByUser[key] = it
manualOrder = append(manualOrder, key)
}
for _, m := range watched {
if !m.Watching {
continue
}
userKey := watchedMyFreeCamsUserKey(m)
if userKey == "" {
continue
}
if _, exists := watchedByUser[userKey]; exists {
continue
}
watchedByUser[userKey] = m
watchedOrder = append(watchedOrder, userKey)
}
runningByUser := map[string]bool{}
hiddenProbeUser := ""
jobsMu.RLock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != "mfc" {
continue
}
u := mfcUserFromURL(j.SourceURL)
if u == "" {
continue
}
runningByUser[u] = true
if j.Hidden && hiddenProbeUser == "" {
hiddenProbeUser = u
}
}
jobsMu.RUnlock()
nextQueue := queue[:0]
nextQueued := map[string]bool{}
for _, it := range queue {
if runningByUser[it.userKey] {
continue
}
if it.source == "manual" {
m, ok := manualByUser[it.userKey]
if !ok {
continue
}
it.url = strings.TrimSpace(m.URL)
if it.url == "" {
continue
}
} else {
m, ok := watchedByUser[it.userKey]
if !ok {
continue
}
it.url = resolveMyFreeCamsURL(m)
if it.url == "" {
continue
}
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
now := time.Now()
blindQueued := false
selectedWatchedBlindUser := ""
if _, ok := watchedByUser[hiddenProbeUser]; ok {
selectedWatchedBlindUser = hiddenProbeUser
}
for _, it := range queue {
if it.blind {
blindQueued = true
if it.source == "watched" && selectedWatchedBlindUser == "" {
selectedWatchedBlindUser = it.userKey
}
break
}
}
candidates := make([]autoStartItem, 0, len(watchedOrder)+len(manualOrder))
nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
// watched MFC Kandidaten
for _, user := range watchedOrder {
m := watchedByUser[user]
u := resolveMyFreeCamsURL(m)
if u == "" {
continue
}
if runningByUser[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown {
continue
}
candidates = append(candidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "watched",
})
}
// manuelle MFC Kandidaten, watched gewinnt bei Kollision
for _, user := range manualOrder {
if _, exists := watchedByUser[user]; exists {
continue
}
itm := manualByUser[user]
u := strings.TrimSpace(itm.URL)
if u == "" {
continue
}
if runningByUser[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown {
continue
}
candidates = append(candidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "manual",
})
}
if !blindQueued && !hasHiddenProbeRunningForProvider("mfc") && len(candidates) > 0 {
n := len(candidates)
start := 0
if probeCursor > 0 {
start = probeCursor % n
}
for i := 0; i < n; i++ {
idx := (start + i) % n
it := candidates[idx]
if queued[it.userKey] || runningByUser[it.userKey] {
continue
}
queue = append(queue, it)
queued[it.userKey] = true
if it.source == "watched" {
selectedWatchedBlindUser = it.userKey
}
probeCursor = (idx + 1) % n
break
}
}
if selectedWatchedBlindUser != "" {
if m, ok := watchedByUser[selectedWatchedBlindUser]; ok {
u := resolveMyFreeCamsURL(m)
if u != "" {
nextProbeAtMs := now.Add(cooldown).UnixMilli()
if t, ok := lastAttempt[selectedWatchedBlindUser]; ok {
nextProbeAtMs = t.Add(cooldown).UnixMilli()
}
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: selectedWatchedBlindUser,
URL: u,
Mode: "probe_retry",
NextProbeAtMs: nextProbeAtMs,
CurrentShow: "offline",
Source: "watched",
})
}
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nextPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
if isAutostartPaused() {
continue
}
if !getSettings().UseMyFreeCamsWatcher {
break
continue
}
u := strings.TrimSpace(m.Input)
if u == "" {
if len(queue) == 0 {
continue
}
modelID := strings.TrimSpace(m.ID)
if modelID == "" {
// Fallback
modelID = strings.TrimSpace(m.Host) + ":" + strings.TrimSpace(m.ModelKey)
}
if modelID == "" {
// Nur EIN Hidden-Probe gleichzeitig
if hasHiddenProbeRunningForProvider("mfc") {
continue
}
// Cooldown
if t, ok := lastAttempt[modelID]; ok && time.Since(t) < cooldown {
it := queue[0]
queue = queue[1:]
delete(queued, it.userKey)
if isJobRunningForURL(it.url) {
continue
}
// Bereits aktiv?
if isJobRunningForURL(u) {
continue
}
lastAttempt[modelID] = time.Now()
lastAttempt[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: u,
URL: it.url,
Hidden: true,
})
if err != nil || job == nil {
continue
}
// Wenn keine echte Output-Datei entsteht -> Job wieder abbrechen/aufräumen
go mfcAbortIfNoOutput(job.ID, outputProbeMax)
time.Sleep(startGap)
if it.source == "manual" {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey)
pendingAutoStartMu.Unlock()
})
} else {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil)
}
}
}
}
@ -120,7 +460,7 @@ func isJobRunningForURL(u string) bool {
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
@ -142,7 +482,10 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
// Output schon da?
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
// echter Download -> im UI sichtbar machen
if onOutput != nil {
onOutput()
}
publishJob(jobID)
return
}

View File

@ -10,6 +10,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
)
@ -74,10 +75,152 @@ func safeUserKeyForFile(v string) string {
return re.ReplaceAllString(v, "_")
}
func pendingProviderFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
host = strings.TrimPrefix(host, "www.")
switch {
case host == "chaturbate.com" || strings.HasSuffix(host, ".chaturbate.com"):
return "chaturbate"
case host == "myfreecams.com" || strings.HasSuffix(host, ".myfreecams.com"):
return "mfc"
default:
return ""
}
}
func pendingAutoStartFilePath(userKey string) string {
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
}
func listPendingAutoStartUserKeys() ([]string, error) {
dir := filepath.Join("data", "pending-autostart")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []string{}, nil
}
return nil, err
}
out := make([]string, 0, len(entries))
globalFile := safeUserKeyForFile(pendingAutoStartGlobalUserKey) + ".json"
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name == "" {
continue
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
if name == globalFile {
continue
}
userKey := strings.TrimSuffix(name, filepath.Ext(name))
if userKey == "" {
continue
}
out = append(out, userKey)
}
sort.Strings(out)
return out, nil
}
func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return nil, errors.New("missing provider")
}
userKeys, err := listPendingAutoStartUserKeys()
if err != nil {
return nil, err
}
out := make([]PendingAutoStartItem, 0, 32)
for _, userKey := range userKeys {
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
return nil, err
}
for _, it := range items {
if normalizePendingSourceServer(it.Source) != "manual" {
continue
}
if pendingProviderFromURL(it.URL) != provider {
continue
}
out = append(out, it)
}
}
return out, nil
}
func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if provider == "" || modelKey == "" {
return nil
}
userKeys, err := listPendingAutoStartUserKeys()
if err != nil {
return err
}
for _, userKey := range userKeys {
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
return err
}
changed := false
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if normalizePendingSourceServer(it.Source) == "manual" &&
pendingProviderFromURL(it.URL) == provider &&
strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey {
changed = true
continue
}
next = append(next, it)
}
if changed {
if err := savePendingAutoStartItems(userKey, next); err != nil {
return err
}
}
}
return nil
}
func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
path := pendingAutoStartFilePath(userKey)
@ -133,36 +276,60 @@ func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, er
return nil, err
}
byKey := make(map[string]PendingAutoStartItem)
out := make([]PendingAutoStartItem, 0, len(globalItems)+len(userItems))
indexByKey := make(map[string]int)
for _, it := range globalItems {
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
if k == "" {
continue
}
byKey[k] = it
indexByKey[k] = len(out)
out = append(out, it)
}
// user-spezifisch überschreibt global
// user-spezifisch überschreibt an gleicher Position
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 {
if idx, ok := indexByKey[k]; ok {
out[idx] = it
continue
}
indexByKey[k] = len(out)
out = append(out, it)
}
return out, nil
}
func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error {
next := make([]PendingAutoStartItem, 0, len(items))
func saveWatchedPendingAutoStartItemsForProvider(provider string, items []PendingAutoStartItem) error {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return errors.New("missing provider")
}
existing, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey)
if err != nil {
return err
}
next := make([]PendingAutoStartItem, 0, len(existing)+len(items))
// alte watched-Einträge dieses Providers entfernen, andere behalten
for _, it := range existing {
if normalizePendingSourceServer(it.Source) == "watched" && pendingProviderFromURL(it.URL) == provider {
continue
}
next = append(next, it)
}
// neue watched-Einträge dieses Providers anhängen
for _, it := range items {
it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey))
it.URL = strings.TrimSpace(it.URL)
@ -181,9 +348,39 @@ func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error {
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
}
func removeWatchedPendingAutoStartItem(modelKey string) error {
func hasHiddenProbeRunningForProvider(provider string) bool {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
return false
}
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
if !j.Hidden {
continue
}
if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != provider {
continue
}
return true
}
return false
}
func removeWatchedPendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
if modelKey == "" {
if provider == "" || modelKey == "" {
return nil
}
@ -194,7 +391,9 @@ func removeWatchedPendingAutoStartItem(modelKey string) error {
next := make([]PendingAutoStartItem, 0, len(items))
for _, it := range items {
if strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey {
if normalizePendingSourceServer(it.Source) == "watched" &&
strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey &&
pendingProviderFromURL(it.URL) == provider {
continue
}
next = append(next, it)

View File

@ -69,6 +69,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/record/delete-many", handleDeleteMany)
api.HandleFunc("/api/record/keep-many", handleKeepMany)
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
mux.HandleFunc("/api/record/release-file", handleReleaseFileTasks)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -50,6 +50,10 @@ func main() {
store := registerRoutes(mux, auth)
setChaturbateOnlineModelStore(store)
if err := clearAllPendingAutoStartOnStartup(); err != nil {
fmt.Println("⚠️ [pending-autostart] startup clear failed:", err)
}
// Hintergrund-Worker
go startChaturbateOnlinePoller(store)
go startChaturbateAutoStartWorker(store)

View File

@ -201,7 +201,7 @@ func runGenerateMissingAssets(ctx context.Context) {
return
}
if errors.Is(err, context.Canceled) {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
st.Error = "Abgebrochen"
} else {
st.Error = err.Error()
@ -209,6 +209,17 @@ func runGenerateMissingAssets(ctx context.Context) {
})
}
abortIfCanceled := func(stepErr error, fileName string) bool {
if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil {
if strings.TrimSpace(fileName) != "" {
clearAssetsTaskFinishedPostworkStates(fileName)
}
finishWithErr(context.Canceled)
return true
}
return false
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
@ -409,6 +420,9 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(merr, it.name) {
return
}
if merr != nil || !okMeta {
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
} else {
@ -430,6 +444,9 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
if abortIfCanceled(terr, it.name) {
return
}
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
} else if finishedPhaseTruthForID(id).ThumbReady {
@ -456,6 +473,9 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(terr, it.name) {
return
}
if terr != nil {
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
} else if finishedPhaseTruthForID(id).TeaserReady {
@ -482,6 +502,9 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
if abortIfCanceled(serr, it.name) {
return
}
if serr != nil {
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
} else if finishedPhaseTruthForID(id).SpritesReady {
@ -505,6 +528,9 @@ func runGenerateMissingAssets(ctx context.Context) {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
if abortIfCanceled(aerr, it.name) {
return
}
if aerr != nil {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
} else if finishedPhaseTruthForID(id).AnalyzeReady {

View File

@ -29,9 +29,11 @@ type cleanupResp struct {
DeletedBytesHuman string `json:"deletedBytesHuman"`
ErrorCount int `json:"errorCount"`
// ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen)
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
// Generated-GC
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
GeneratedOrphansRemovedBytes int64 `json:"generatedOrphansRemovedBytes"`
GeneratedOrphansRemovedBytesHuman string `json:"generatedOrphansRemovedBytesHuman"`
}
// Optional: falls du später Threshold per Body überschreiben willst.
@ -103,11 +105,16 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
gcStats := triggerGeneratedGarbageCollectorSync()
resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes)
// ✅ Generated-GC zur insgesamt freigegebenen Größe addieren
resp.DeletedBytes += gcStats.RemovedBytes
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
orphansTotalRemoved := resp.GeneratedOrphansRemoved
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
setCleanupTaskDone(
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
)
time.AfterFunc(7*time.Second, func() {
clearCleanupTaskState()
@ -116,6 +123,15 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
return fmt.Sprintf(
"gelöscht: %d/%d · freigegeben: %s",
deleted,
total,
formatBytesSI(freedBytes),
)
}
func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
isCandidate := func(name string) bool {
low := strings.ToLower(name)
@ -201,38 +217,76 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
resp.ScannedFiles = len(candidates)
setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…")
setCleanupTaskProgress(
0,
resp.ScannedFiles,
"",
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
)
for i, c := range candidates {
setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name))
if c.size < threshold {
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base)
if c.size >= threshold {
continue
}
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += c.size
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base)
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += c.size
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
purgeDurationCacheForPath(c.path)
} else {
resp.ErrorCount++
}
purgeDurationCacheForPath(c.path)
} else {
resp.ErrorCount++
}
setCleanupTaskProgress(
i+1,
resp.ScannedFiles,
"",
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
)
}
}
var generatedGCRunning int32
type generatedGCStats struct {
Checked int
Removed int
Checked int
Removed int
RemovedBytes int64
}
func dirSizeBytes(root string) int64 {
root = strings.TrimSpace(root)
if root == "" {
return 0
}
var total int64
_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
if info.Size() > 0 {
total += info.Size()
}
return nil
})
return total
}
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
@ -338,6 +392,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
removedMeta := 0
checkedMeta := 0
removedBytes := int64(0)
if entries, err := os.ReadDir(metaRoot); err == nil {
for _, e := range entries {
@ -354,14 +409,22 @@ func runGeneratedGarbageCollector() generatedGCStats {
continue
}
orphanDir := filepath.Join(metaRoot, id)
orphanBytes := dirSizeBytes(orphanDir)
removeGeneratedForID(id)
removedMeta++
if _, err := os.Stat(orphanDir); os.IsNotExist(err) {
removedMeta++
removedBytes += orphanBytes
}
}
}
//fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
//fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d bytes=%d\n", checkedMeta, removedMeta, removedBytes)
stats.Checked += checkedMeta
stats.Removed += removedMeta
stats.RemovedBytes += removedBytes
return stats
}

View File

@ -0,0 +1,215 @@
// backend\tasks_release_file.go
package main
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"time"
)
type releaseFileTasksRequest struct {
File string `json:"file"`
}
type releaseFileTasksResponse struct {
OK bool `json:"ok"`
File string `json:"file"`
ReleasedAny bool `json:"releasedAny"`
AssetsTaskCancelled bool `json:"assetsTaskCancelled"`
RegenerateCancelled bool `json:"regenerateCancelled"`
PostworkCancelled bool `json:"postworkCancelled"`
EnrichCancelled bool `json:"enrichCancelled"`
}
func assetIDFromAnyFileName(file string) string {
base := strings.TrimSpace(filepath.Base(strings.TrimSpace(file)))
if base == "" {
return ""
}
stem := strings.TrimSuffix(base, filepath.Ext(base))
return strings.TrimSpace(stripHotPrefix(stem))
}
func sameBaseFile(a, b string) bool {
aa := strings.TrimSpace(filepath.Base(strings.TrimSpace(a)))
bb := strings.TrimSpace(filepath.Base(strings.TrimSpace(b)))
if aa == "" || bb == "" {
return false
}
return strings.EqualFold(aa, bb)
}
func cancelAssetsTaskForFile(file string) bool {
assetsTaskMu.Lock()
cancel := assetsTaskCancel
running := assetsTaskState.Running
currentFile := assetsTaskState.CurrentFile
assetsTaskMu.Unlock()
if !running || cancel == nil {
return false
}
if !sameBaseFile(currentFile, file) {
return false
}
cancel()
return true
}
func cancelRegenerateAssetsTaskForFile(file string) bool {
regenerateAssetsMu.Lock()
var matched []*regenerateAssetsJob
for _, job := range regenerateAssetsJobs {
if job == nil {
continue
}
if !sameBaseFile(job.File, file) {
continue
}
matched = append(matched, job)
}
regenerateAssetsMu.Unlock()
cancelled := false
for _, job := range matched {
if job != nil && job.Cancel != nil {
job.Cancel()
cancelled = true
}
}
return cancelled
}
func cancelQueuesForFile(file string) (postworkCancelled bool, enrichCancelled bool) {
file = strings.TrimSpace(file)
// 1) Direkt aus Dateiname ableiten:
// Für laufende Sprites ist das meistens der relevante Queue-Key.
if assetID := assetIDFromAnyFileName(file); assetID != "" {
enrichKey := "enrich:" + assetID
if enrichQ.Cancel(enrichKey) {
enrichCancelled = true
}
if enrichQ.RemoveQueued(enrichKey) {
enrichCancelled = true
}
clearEnrichStatusSnapshot(enrichKey)
}
// 2) Zusätzlich wie bisher über jobs suchen
type queueKeys struct {
postworkKey string
enrichKey string
}
matches := make([]queueKeys, 0, 8)
jobsMu.RLock()
for _, job := range jobs {
if job == nil {
continue
}
if !sameBaseFile(job.Output, file) {
continue
}
postworkKey := strings.TrimSpace(job.PostWorkKey)
enrichKey := ""
if id := strings.TrimSpace(assetIDForJob(job)); id != "" {
enrichKey = "enrich:" + id
}
matches = append(matches, queueKeys{
postworkKey: postworkKey,
enrichKey: enrichKey,
})
}
jobsMu.RUnlock()
for _, item := range matches {
if item.postworkKey != "" {
if postWorkQ.Cancel(item.postworkKey) {
postworkCancelled = true
}
if postWorkQ.RemoveQueued(item.postworkKey) {
postworkCancelled = true
}
}
if item.enrichKey != "" {
if enrichQ.Cancel(item.enrichKey) {
enrichCancelled = true
}
if enrichQ.RemoveQueued(item.enrichKey) {
enrichCancelled = true
}
clearEnrichStatusSnapshot(item.enrichKey)
}
}
return postworkCancelled, enrichCancelled
}
func releaseFileTasks(file string) releaseFileTasksResponse {
file = strings.TrimSpace(file)
resp := releaseFileTasksResponse{
OK: true,
File: file,
}
if file == "" {
return resp
}
resp.AssetsTaskCancelled = cancelAssetsTaskForFile(file)
resp.RegenerateCancelled = cancelRegenerateAssetsTaskForFile(file)
resp.PostworkCancelled, resp.EnrichCancelled = cancelQueuesForFile(file)
resp.ReleasedAny =
resp.AssetsTaskCancelled ||
resp.RegenerateCancelled ||
resp.PostworkCancelled ||
resp.EnrichCancelled
// ffmpeg / Analyse / Sprite-Generierung einen Moment zum Beenden geben
if resp.ReleasedAny {
time.Sleep(900 * time.Millisecond)
}
return resp
}
func handleReleaseFileTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
var req releaseFileTasksRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
file := strings.TrimSpace(req.File)
if file == "" {
file = strings.TrimSpace(r.URL.Query().Get("file"))
}
if file == "" {
http.Error(w, "file fehlt", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, releaseFileTasks(file))
}

View File

@ -172,14 +172,6 @@ type PendingWatchedRoom = {
imageUrl?: string
}
type ParsedModel = {
input: string
isUrl: boolean
host?: string
path?: string
modelKey: string
}
type ChaturbateOnlineRoomItem = {
username?: string
current_show?: string
@ -1564,15 +1556,6 @@ export default function App() {
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(),
})
const pendingUrl = String((pendingAutoStartByKeyRef.current || {})[modelKey] ?? '').trim()
if (roomStatus === 'public' && pendingUrl && !busyRef.current) {
enqueueStart({
url: pendingUrl,
silent: true,
pendingKeyLower: modelKey,
})
}
}
}
}
@ -1697,13 +1680,14 @@ export default function App() {
const lastClipboardUrlRef = useRef<string>('')
// --- START QUEUE (parallel) ---
const START_CONCURRENCY = 4 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start
const START_CONCURRENCY = 1 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start
type StartQueueItem = {
url: string
silent: boolean
pendingKeyLower?: string
hidden?: boolean
source?: PendingAutoStartSource
}
const startQueueRef = useRef<StartQueueItem[]>([])
@ -1808,9 +1792,14 @@ export default function App() {
try {
const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden })
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
if (ok && next.pendingKeyLower) {
removePendingAutoStart(next.pendingKeyLower)
if (next.pendingKeyLower && next.source === 'manual') {
if (ok && !next.hidden) {
removePendingAutoStart(next.pendingKeyLower)
}
if (!ok) {
removePendingAutoStart(next.pendingKeyLower)
}
}
} finally {
// dedupe wieder freigeben
@ -2146,50 +2135,7 @@ export default function App() {
doneSortRef.current = doneSort
}, [doneSort])
const applyPendingRoomSnapshot = useCallback(
(
keyLower: string,
snap: {
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
chatRoomUrl?: string
}
) => {
if (!keyLower) return
setModelsByKey((prev) => {
const cur = prev[keyLower]
if (!cur) return prev
return {
...prev,
[keyLower]: {
...cur,
roomStatus: snap.show,
isOnline: snap.show !== 'offline',
imageUrl: snap.imageUrl || cur.imageUrl,
chatRoomUrl: snap.chatRoomUrl || cur.chatRoomUrl,
},
}
})
setPendingWatchedRooms((prev) => {
const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
if (idx < 0) return prev
const copy = [...prev]
copy[idx] = {
...copy[idx],
currentShow: snap.show,
imageUrl: snap.imageUrl || copy[idx].imageUrl,
}
return copy
})
},
[]
)
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
const modelsByKeyRef = useRef(modelsByKey)
useEffect(() => {
modelsByKeyRef.current = modelsByKey
@ -2253,173 +2199,14 @@ export default function App() {
}, [pendingAutoStartByKey, modelsByKey])
useEffect(() => {
if (!recSettings.useChaturbateApi) {
return
}
if (!authed) return
let cancelled = false
let timer: number | null = null
let inFlight = false
const id = window.setInterval(() => {
void loadPendingAutoStarts()
}, document.hidden ? 8000 : 3000)
const tick = async () => {
if (cancelled || inFlight) return
const pendingMap = pendingAutoStartByKeyRef.current || {}
const entries = Object.entries(pendingMap)
if (entries.length === 0) return
if (busyRef.current) return
const keys = entries
.map(([rawKey]) => String(rawKey ?? '').trim().toLowerCase())
.filter(Boolean)
if (keys.length === 0) return
inFlight = true
try {
const res = await fetch('/api/chaturbate/online', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: keys,
}),
})
const byKey: Record<
string,
{
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
chatRoomUrl?: string
}
> = {}
if (res.ok) {
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
for (const room of rooms) {
const key = String(room?.username ?? '').trim().toLowerCase()
if (!key) continue
byKey[key] = {
show: normalizePendingShow(room?.current_show),
imageUrl: String(room?.image_url ?? '').trim() || undefined,
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
}
}
}
const modeMap = pendingAutoStartModeByKeyRef.current || {}
const retryAtMap = pendingBlindRetryAtByKeyRef.current || {}
for (const key of keys) {
const snap =
byKey[key] ?? {
show: 'unknown' as const,
imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
}
maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl,
})
applyPendingRoomSnapshot(key, snap)
const url = String((pendingMap as any)?.[key] ?? '').trim()
if (!url) continue
const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode
const source =
(pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource
if (snap.show === 'public') {
enqueueStart({
url,
silent: true,
pendingKeyLower: key,
})
continue
}
if (mode === 'probe_retry') {
// Sobald API das Model als private/hidden/away kennt:
// nicht mehr blind probieren, sondern einfach auf public warten.
if (
snap.show === 'private' ||
snap.show === 'hidden' ||
snap.show === 'away'
) {
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
mode: 'wait_public',
source,
})
continue
}
const nextProbeAtMs = Number(retryAtMap[key] ?? 0)
if (!busyRef.current && Date.now() >= nextProbeAtMs) {
enqueueStart({
url,
silent: true,
hidden: true,
})
queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source,
})
}
continue
}
// mode === 'wait_public'
// private / hidden / away / offline / unknown => einfach warten
}
} catch {
// ignore
} finally {
inFlight = false
}
}
const schedule = (ms?: number) => {
if (cancelled) return
const retryMap = pendingBlindRetryAtByKeyRef.current || {}
const hasProbeRetry = Object.keys(retryMap).length > 0
const nextMs =
ms ??
(hasProbeRetry ? 1000 : (document.hidden ? 8000 : 3000))
timer = window.setTimeout(async () => {
await tick()
schedule()
}, nextMs)
}
schedule(0)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
}
}, [
recSettings.useChaturbateApi,
applyPendingRoomSnapshot,
enqueueStart,
maybeNotifyWatchedModelStatusChange,
removePendingAutoStart,
])
return () => window.clearInterval(id)
}, [authed, loadPendingAutoStarts])
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
@ -2568,107 +2355,25 @@ export default function App() {
})
if (alreadyRunning) return true
// Chaturbate-Startlogik mit Online-API:
// public -> direkt starten
// private/hidden/away -> wait_public
// offline/unknown -> probe_retry mit hidden start
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
try {
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: norm }),
})
// Backend-only Autostart:
// Frontend speichert nur pending, Backend startet eigenständig.
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
const keyLower = providerKeyLowerFromUrl(norm)
if (mkLower) {
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
let resolvedImageUrl: string | undefined
let resolvedChatRoomUrl: string | undefined
let foundInApi = false
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
source: 'manual',
})
return true
}
try {
const onlineRes = await fetch('/api/chaturbate/online', {
method: 'POST',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
q: [mkLower],
refresh: true,
}),
})
if (onlineRes.ok) {
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
const room = Array.isArray(onlineData?.rooms)
? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
: null
if (room) {
foundInApi = true
resolvedShow = normalizePendingShow(room.current_show)
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
}
}
} catch {
// best effort
}
applyPendingRoomSnapshot(mkLower, {
show: resolvedShow,
imageUrl: resolvedImageUrl,
chatRoomUrl: resolvedChatRoomUrl,
})
// 1) in API + public => sofort normal starten
if (foundInApi && resolvedShow === 'public') {
removePendingAutoStart(mkLower)
// unten normal weiter
} else if (
foundInApi &&
(resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away')
) {
// 2) in API + nicht public => warten bis public
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
mode: 'wait_public',
source: 'manual',
})
return true
} else {
// 3) nicht in API ODER offline/unknown => Blind-Probe jetzt starten
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source: 'manual',
})
const ok = await doStartNow(norm, silent, { hidden: true })
if (!ok) {
removePendingAutoStart(mkLower)
return false
}
return true
}
}
} catch {
const mkLower = providerKeyLowerFromUrl(norm)
if (mkLower) {
queuePendingAutoStart(mkLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
nextProbeAtMs: Date.now() + 60_000,
source: 'manual',
})
const ok = await doStartNow(norm, silent, { hidden: true })
if (!ok) {
removePendingAutoStart(mkLower)
return false
}
return true
}
}
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
mode: 'probe_retry',
source: 'manual',
})
return true
}
try {
@ -3833,8 +3538,7 @@ export default function App() {
if (autoAddEnabled) setSourceUrl(url)
if (autoStartEnabled) {
// ✅ immer enqueue (dedupe verhindert doppelt)
enqueueStart({ url, silent: false })
void startUrl(url, { silent: false })
}
} catch {
// ignore
@ -3866,7 +3570,7 @@ export default function App() {
window.removeEventListener('focus', kick)
document.removeEventListener('visibilitychange', kick)
}
}, [autoAddEnabled, autoStartEnabled, enqueueStart])
}, [autoAddEnabled, autoStartEnabled, startUrl])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
@ -4187,8 +3891,6 @@ export default function App() {
key={[
String((playerJob as any)?.id ?? ''),
baseName(playerJob.output || ''),
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
String(assetNonce),
].join('::')}
job={playerJob}
modelKey={playerModelKey ?? undefined}

View File

@ -37,6 +37,7 @@ type Props = {
liked?: boolean | null
watching?: boolean
roomStatus?: string
imageUrl?: string
}
>
roomStatusByModelKey?: Record<string, string>
@ -67,6 +68,13 @@ const pendingModelName = (p: PendingWatchedRoom) => {
)
}
const shouldRefreshPreview = (j: RecordJob) => {
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
const status = String(j.status ?? '').trim().toLowerCase()
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
}
const pendingUrl = (p: PendingWatchedRoom) => {
const anyP = p as any
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
@ -119,6 +127,27 @@ const modelKeyFromJob = (job: RecordJob): string => {
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const onlineImageUrlOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
const anyJ = job as any
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()
if (direct) return direct
const modelKey = modelKeyFromJob(job)
if (!modelKey) return ''
return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
@ -578,14 +607,16 @@ export default function Downloads({
}, [])
useEffect(() => {
const activeLiveJobs = jobs.filter((j) => {
if ((j as any).endedAt) return false
return String(j.status ?? '').trim().toLowerCase() === 'running'
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 (activeLiveJobs === 0 || !pageVisible) return
if (activePreviewJobs === 0 || !pageVisible) return
const intervalMs = activeLiveJobs > 2 ? 30000 : 10000
const intervalMs = activePreviewJobs > 2 ? 30000 : 10000
const id = window.setInterval(() => {
setThumbTick((t) => t + 1)
@ -940,6 +971,9 @@ export default function Downloads({
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey)
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<LazyMountWhenVisible
@ -948,8 +982,9 @@ export default function Downloads({
>
<ModelPreview
jobId={j.id}
live={false}
thumbTick={thumbTick}
initialSrc={onlineImageUrl || providerFallbackSrc}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
enableHoverLive={true}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
j,
@ -957,7 +992,7 @@ export default function Downloads({
modelsByKey,
growingByJobId
)}
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
fallbackSrc={providerFallbackSrc}
className="w-full h-full"
/>
</LazyMountWhenVisible>

View File

@ -30,6 +30,7 @@ type ModelFlags = {
liked?: boolean | null
watching?: boolean
roomStatus?: string
imageUrl?: string
}
type Props = {
@ -125,6 +126,27 @@ const modelKeyFromJob = (job: RecordJob): string => {
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const onlineImageUrlOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
const anyJ = job as any
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()
if (direct) return direct
const modelKey = modelKeyFromJob(job)
if (!modelKey) return ''
return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
@ -638,6 +660,13 @@ export default function DownloadsCardRow({
const isLiked = flags?.liked === true
const isWatching = Boolean(flags?.watching)
const shouldRefreshPreview = (j: RecordJob) => {
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
const status = String(j.status ?? '').trim().toLowerCase()
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
}
return (
<div
className="
@ -674,8 +703,12 @@ export default function DownloadsCardRow({
>
<ModelPreview
jobId={j.id}
live={true}
thumbTick={thumbTick}
initialSrc={
onlineImageUrlOfJob(j, modelsByKey) ||
`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
}
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
enableHoverLive={false}
blur={blurPreviews}
roomStatus={previewRoomStatusOfJob(
j,

View File

@ -338,6 +338,10 @@ function looksLikeFileInUseError(err: unknown): boolean {
s.includes('sharing violation') ||
s.includes('used by another process') ||
s.includes('file in use') ||
s.includes('cannot access the file') ||
s.includes('access is denied') ||
s.includes('zugriff verweigert') ||
s.includes('wird von einem anderen prozess verwendet') ||
s.includes('409')
)
}
@ -1436,6 +1440,11 @@ export default function FinishedDownloads({
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
const [galleryColumnCount, setGalleryColumnCount] = React.useState<number>(1)
const [allDoneJobs, setAllDoneJobs] = React.useState<RecordJob[] | null>(null)
const [allDoneJobsLoading, setAllDoneJobsLoading] = React.useState(false)
const allDoneJobsRequestRef = React.useRef(0)
const [tagFilter, setTagFilter] = React.useState<string[]>([])
const [searchQuery, setSearchQuery] = React.useState<string>('')
@ -1540,7 +1549,7 @@ export default function FinishedDownloads({
)
const searchActiveForGlobalFetch =
activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2)
activeTagSet.size > 0 || searchTokens.length > 0
const globalFilterActive = searchActiveForGlobalFetch
const effectiveAllMode = globalFilterActive || loadMode === 'all'
@ -1552,8 +1561,15 @@ export default function FinishedDownloads({
? galleryPageSize
: pageSize
const doneJobsPage = doneJobs
const doneTotalPage = doneTotal
const doneJobsPage =
globalFilterActive
? (allDoneJobs ?? doneJobs)
: doneJobs
const doneTotalPage =
globalFilterActive
? (allDoneJobs?.length ?? doneTotal)
: doneTotal
const applyRenamedOutputLocal = useCallback(
(job: RecordJob): RecordJob => {
@ -1888,6 +1904,30 @@ export default function FinishedDownloads({
const clearTagFilter = useCallback(() => setTagFilter([]), [])
const loadAllDoneJobs = useCallback(async (): Promise<RecordJob[]> => {
const res = await fetch(
`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
return items
}, [sortMode, includeKeep])
const flushResolutionsSoon = useCallback(() => {
if (resolutionsFlushTimerRef.current != null) return
resolutionsFlushTimerRef.current = window.setTimeout(() => {
@ -2150,16 +2190,38 @@ export default function FinishedDownloads({
[]
)
const forceReleaseFileTasks = useCallback(async (file: string) => {
const cleanFile = String(file || '').trim()
if (!cleanFile) return false
try {
const res = await fetch('/api/record/release-file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({ file: cleanFile }),
})
if (!res.ok) return false
const data = (await res.json().catch(() => null)) as any
return Boolean(data?.releasedAny)
} catch {
return false
}
}, [])
const withFileReleaseRetry = useCallback(
async <T,>(
file: string,
run: () => Promise<T>,
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
): Promise<T> => {
const attempts = Math.max(1, opts?.attempts ?? 4)
const attempts = Math.max(1, opts?.attempts ?? 5)
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
let lastErr: unknown
let taskReleaseTriggered = false
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
@ -2174,10 +2236,25 @@ export default function FinishedDownloads({
return await run()
} catch (e) {
lastErr = e
if (!looksLikeFileInUseError(e) || attempt >= attempts) {
if (!looksLikeFileInUseError(e)) {
throw e
}
// Beim ersten "file in use" zusätzlich Backend-Tasks abbrechen
if (!taskReleaseTriggered) {
taskReleaseTriggered = true
await releasePlayingFile(file, { close: true })
await forceReleaseFileTasks(file)
await sleep(450)
continue
}
if (attempt >= attempts) {
throw e
}
continue
}
}
@ -2185,7 +2262,7 @@ export default function FinishedDownloads({
? lastErr
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
},
[releasePlayingFile]
[releasePlayingFile, forceReleaseFileTasks]
)
const runFileMutation = useCallback(
@ -2886,6 +2963,46 @@ export default function FinishedDownloads({
// -----------------------------------------------------------------------------
// effects
// -----------------------------------------------------------------------------
useEffect(() => {
if (!globalFilterActive) return
if (page !== 1) onPageChange(1)
}, [globalFilterActive, deferredSearchQuery, tagFilter, page, onPageChange])
useEffect(() => {
if (!globalFilterActive) {
setAllDoneJobs(null)
setAllDoneJobsLoading(false)
return
}
let cancelled = false
const reqId = ++allDoneJobsRequestRef.current
setAllDoneJobsLoading(true)
void loadAllDoneJobs()
.then((items) => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobs(items)
})
.catch(() => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobs([])
})
.finally(() => {
if (cancelled) return
if (allDoneJobsRequestRef.current !== reqId) return
setAllDoneJobsLoading(false)
})
return () => {
cancelled = true
}
}, [globalFilterActive, loadAllDoneJobs])
useEffect(() => {
if (view !== 'gallery') return
@ -4098,7 +4215,7 @@ export default function FinishedDownloads({
}}
isSmall={isSmall}
jobForDetails={jobForDetails}
isLoading={false}
isLoading={allDoneJobsLoading}
blurPreviews={blurPreviews}
durations={durations}
teaserState={teaserState}
@ -4160,7 +4277,7 @@ export default function FinishedDownloads({
{view === 'table' && (
<FinishedDownloadsTableView
rows={pageRows}
isLoading={false}
isLoading={allDoneJobsLoading}
selectedKeys={selectedKeys}
onToggleSelected={toggleSelected}
onToggleSelectAllPage={(checked) => {
@ -4231,7 +4348,7 @@ export default function FinishedDownloads({
if (checked) selectPageRows(pageRows)
else deselectPageRows(pageRows)
}}
isLoading={false}
isLoading={allDoneJobsLoading}
jobForDetails={jobForDetails}
blurPreviews={blurPreviews}
durations={durations}

View File

@ -765,7 +765,7 @@ export default function FinishedDownloadsCardsView({
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
aria-label="Teaser mit Ton abspielen"
title="Ton einschalten"
title="Ton aktivieren"
>
<SpeakerWaveIcon className="size-3.5 shrink-0" />
<span>Ton aktivieren</span>

View File

@ -351,6 +351,7 @@ export default function FinishedVideoPreview({
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
const clipsRef = useRef<HTMLVideoElement | null>(null)
const teaserSoundForcedRef = useRef(false)
const teaserMutedRef = useRef(muted)
const setTeaserPlaybackRate = useCallback((rate: number) => {
const el = teaserMp4Ref.current
@ -364,6 +365,24 @@ export default function FinishedVideoPreview({
}
}, [])
useEffect(() => {
teaserMutedRef.current = muted
const el = teaserMp4Ref.current
if (!el) return
const effectiveMuted = teaserSoundForcedRef.current ? false : teaserMutedRef.current
applyInlineVideoPolicy(el, { muted: effectiveMuted })
try {
el.muted = effectiveMuted
el.defaultMuted = effectiveMuted
} catch {
// ignore
}
}, [muted])
const playTeaserFromGesture = useCallback((withSound = true) => {
const el = teaserMp4Ref.current
if (!el) return false
@ -817,7 +836,7 @@ export default function FinishedVideoPreview({
)
const teaserActivatedSoftly =
(animatedTrigger === 'hover' && hovered) || forceActive
animatedTrigger === 'hover' && hovered
const showTeaserLoadingOverlay =
!showingInlineVideo &&
@ -978,7 +997,7 @@ export default function FinishedVideoPreview({
return
}
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
const effectiveMuted = teaserSoundForcedRef.current ? false : teaserMutedRef.current
applyInlineVideoPolicy(el, { muted: effectiveMuted })
@ -1103,7 +1122,7 @@ export default function FinishedVideoPreview({
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
window.clearInterval(watchdog)
}
}, [teaserActive, animatedMode, teaserSrc, muted, pageVisible, teaserSpeedHold, setTeaserPlaybackRate])
}, [teaserActive, animatedMode, teaserSrc, pageVisible])
useEffect(() => {
if (!activationNonce) return
@ -1167,7 +1186,7 @@ export default function FinishedVideoPreview({
window.clearTimeout(retryTimer)
}
}
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo, muted])
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo])
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
useEffect(() => {

View File

@ -13,14 +13,14 @@ import LoadingSpinner from './LoadingSpinner'
type Props = {
jobId: string
modelKey?: string
live?: boolean
thumbTick?: number
enableHoverLive?: boolean
refreshKey?: number
blur?: boolean
className?: string
fit?: 'cover' | 'contain'
roomStatus?: string
fallbackSrc?: string
initialSrc?: string
}
function buildChaturbateCookieHeader(): string {
@ -41,13 +41,14 @@ function buildChaturbateCookieHeader(): string {
export default function ModelPreview({
jobId,
live = false,
thumbTick,
enableHoverLive = false,
refreshKey,
blur = false,
className,
fit = 'cover',
roomStatus,
fallbackSrc,
initialSrc,
}: Props) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
@ -82,17 +83,19 @@ export default function ModelPreview({
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const previewSrc = useMemo(() => {
const explicit = String(initialSrc ?? '').trim()
if (explicit) {
return explicit
}
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
if (!live) return base
// thumbTick kommt zentral vom Parent
if (typeof thumbTick === 'number') {
return `${base}&v=${thumbTick}`
if (typeof refreshKey === 'number') {
return `${base}&v=${refreshKey}`
}
return base
}, [jobId, live, thumbTick])
}, [initialSrc, jobId, refreshKey])
const fallbackImgSrc = useMemo(() => {
const s = String(fallbackSrc ?? '').trim()
@ -100,16 +103,11 @@ export default function ModelPreview({
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
}, [fallbackSrc, jobId])
const liveStreamSrc = useMemo(
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1`,
[jobId]
)
useEffect(() => {
const ac = new AbortController()
const loadLive = async () => {
if (!live || !hoverOpen) {
if (!enableHoverLive || !hoverOpen) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
@ -122,7 +120,6 @@ export default function ModelPreview({
const cookieHeader = buildChaturbateCookieHeader()
try {
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
const prepareRes = await fetch(
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`,
{
@ -136,31 +133,21 @@ export default function ModelPreview({
)
if (ac.signal.aborted) return
// Auch wenn prepare fehlschlägt:
// best effort -> Stream-Endpunkt trotzdem versuchen
await prepareRes.json().catch(() => null)
if (ac.signal.aborted) return
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
} catch {
if (ac.signal.aborted) return
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
} finally {
if (!ac.signal.aborted) {
setLivePreparing(false)
}
if (!ac.signal.aborted) setLivePreparing(false)
}
}
void loadLive()
return () => {
ac.abort()
}
}, [hoverOpen, live, jobId, liveStreamSrc])
return () => ac.abort()
}, [enableHoverLive, hoverOpen, jobId])
useEffect(() => {
const el = rootRef.current
@ -192,15 +179,18 @@ export default function ModelPreview({
return (
<HoverPopover
onOpenChange={(open) => {
setHoverOpen(open)
if (!open) {
const nextOpen = enableHoverLive && open
setHoverOpen(nextOpen)
if (!nextOpen) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}
}}
content={(open, { close }) =>
open && (
enableHoverLive && open ? (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
<div
className="relative rounded-lg overflow-hidden bg-black"
@ -276,7 +266,7 @@ export default function ModelPreview({
</div>
</div>
</div>
)
) : null
}
>
<div

View File

@ -923,7 +923,7 @@ export default function Player({
}
}
}
}, [mounted, startMuted, isLive, metaReady, videoH, updateIntrinsicDims])
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
React.useEffect(() => {
const p = playerRef.current

View File

@ -419,7 +419,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
...t,
status: 'running',
title: 'Aufräumen',
text: String(cleanup.currentFile || cleanup.text || 'Räume auf…'),
text: String(cleanup.text || cleanup.currentFile || 'Räume auf…'),
done: Number(cleanup.done ?? 0),
total: Number(cleanup.total ?? 0),
err: undefined,
@ -711,11 +711,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const data = await res.json()
const scannedFiles = Number(data.scannedFiles ?? 0)
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
const orphansTotalRemoved = orphanRemoved + genRemoved
const deletedFiles = Number(data.deletedFiles ?? 0)
const deletedBytesHuman = String(data.deletedBytesHuman ?? '0 B')
setCleanupTask((t) => ({
...t,
@ -723,7 +720,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
done: scannedFiles,
total: scannedFiles,
title: 'Aufräumen',
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
text: `gelöscht: ${deletedFiles}/${scannedFiles} · freigegeben: ${deletedBytesHuman}`,
}))
fadeOutTask(setCleanupTask, 7000, 500)

View File

@ -1683,7 +1683,12 @@ export default function VideoSplitModal({
<div className="flex justify-end">
<button
type="button"
className="rounded-full cursor-pointer select-none bg-black/45 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur-sm transition hover:bg-black/60"
className="
rounded-full cursor-pointer select-none
px-2.5 py-1 text-[11px] font-medium backdrop-blur-sm transition
bg-white/95 text-gray-900 ring-1 ring-gray-200 shadow-sm hover:bg-white
dark:bg-black/70 dark:text-white dark:ring-white/10 dark:hover:bg-black/80
"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()