fixed bugs & UI improvements
This commit is contained in:
parent
e5f506ec45
commit
e25aca59c3
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
215
backend/tasks_release_file.go
Normal file
215
backend/tasks_release_file.go
Normal 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))
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
@ -363,6 +364,24 @@ export default function FinishedVideoPreview({
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
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
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user