fixed bugs & UI improvements
This commit is contained in:
parent
e5f506ec45
commit
e25aca59c3
@ -131,20 +131,27 @@ func generatePreviewSpriteJPG(
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
msgErr := strings.TrimSpace(stderr.String())
|
msgErr := strings.TrimSpace(stderr.String())
|
||||||
if msgErr == "" {
|
if msgErr == "" {
|
||||||
msgErr = "(kein stderr)"
|
msgErr = "(kein stderr)"
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
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,
|
videoPath,
|
||||||
tmpPath,
|
tmpPath,
|
||||||
err,
|
err,
|
||||||
ctx.Err(),
|
|
||||||
strings.Join(cmd.Args, " "),
|
strings.Join(cmd.Args, " "),
|
||||||
msgErr,
|
msgErr,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -3,9 +3,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -14,6 +16,8 @@ import (
|
|||||||
type autoStartItem struct {
|
type autoStartItem struct {
|
||||||
userKey string
|
userKey string
|
||||||
url string
|
url string
|
||||||
|
blind bool
|
||||||
|
source string // "watched" | "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
func normUser(s string) string {
|
func normUser(s string) string {
|
||||||
@ -45,14 +49,27 @@ func chaturbateUserFromURL(raw string) string {
|
|||||||
if raw == "" {
|
if raw == "" {
|
||||||
return ""
|
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)
|
u, err := url.Parse(raw)
|
||||||
if err != nil || u.Hostname() == "" {
|
if err != nil || u.Hostname() == "" {
|
||||||
return ""
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(u.Path, "/")
|
parts := strings.Split(u.Path, "/")
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
@ -60,6 +77,7 @@ func chaturbateUserFromURL(raw string) string {
|
|||||||
return normUser(p)
|
return normUser(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,15 +117,11 @@ func cookieHeaderFromSettings(s RecorderSettings) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveChaturbateURL(m WatchedModelLite) string {
|
func resolveChaturbateURL(m WatchedModelLite) string {
|
||||||
in := strings.TrimSpace(m.Input)
|
if u := chaturbateUserFromURL(m.Input); u != "" {
|
||||||
if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
|
return fmt.Sprintf("https://chaturbate.com/%s/", u)
|
||||||
return in
|
|
||||||
}
|
}
|
||||||
|
|
||||||
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
|
key := watchedChaturbateUserKey(m)
|
||||||
if key == "" {
|
|
||||||
key = watchedChaturbateUserKey(m)
|
|
||||||
}
|
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -115,7 +129,7 @@ func resolveChaturbateURL(m WatchedModelLite) string {
|
|||||||
return fmt.Sprintf("https://chaturbate.com/%s/", key)
|
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)
|
deadline := time.Now().Add(maxWait)
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@ -150,6 +164,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if onOutput != nil {
|
||||||
|
onOutput()
|
||||||
|
}
|
||||||
|
|
||||||
publishJob(jobID)
|
publishJob(jobID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -195,6 +213,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if onNoOutput != nil {
|
||||||
|
onNoOutput()
|
||||||
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
j := jobs[jobID]
|
j := jobs[jobID]
|
||||||
delete(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
|
// Startet watched+online(public) automatisch – unabhängig vom Frontend
|
||||||
func startChaturbateAutoStartWorker(store *ModelStore) {
|
func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@ -232,6 +289,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
queued := map[string]bool{}
|
queued := map[string]bool{}
|
||||||
lastTry := map[string]time.Time{}
|
lastTry := map[string]time.Time{}
|
||||||
lastBlindTry := map[string]time.Time{}
|
lastBlindTry := map[string]time.Time{}
|
||||||
|
probeCursor := 0
|
||||||
|
|
||||||
scanTicker := time.NewTicker(scanInterval)
|
scanTicker := time.NewTicker(scanInterval)
|
||||||
startTicker := time.NewTicker(startInterval)
|
startTicker := time.NewTicker(startInterval)
|
||||||
@ -252,6 +310,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
lastCookieHdr = ""
|
lastCookieHdr = ""
|
||||||
|
|
||||||
|
pendingAutoStartMu.Lock()
|
||||||
|
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nil)
|
||||||
|
pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,20 +339,56 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
showByUser := map[string]string{}
|
showByUser := map[string]string{}
|
||||||
imageByUser := 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 {
|
for _, r := range rooms {
|
||||||
u := normUser(r.Username)
|
u := normUser(r.Username)
|
||||||
if u == "" {
|
if u == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenInAPI[u] = true
|
|
||||||
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
|
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
|
||||||
imageByUser[u] = selectBestRoomImageURL(r)
|
imageByUser[u] = selectBestRoomImageURL(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// laufende Jobs sammeln
|
// laufende Jobs sammeln
|
||||||
runningByUser := map[string]*RecordJob{}
|
runningByUser := map[string]*RecordJob{}
|
||||||
|
hiddenProbeUser := ""
|
||||||
|
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
@ -301,9 +400,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
|
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
u := chaturbateUserFromURL(j.SourceURL)
|
u := chaturbateUserFromURL(j.SourceURL)
|
||||||
if u != "" {
|
if u == "" {
|
||||||
runningByUser[u] = j
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
runningByUser[u] = j
|
||||||
|
|
||||||
|
if j.Hidden && hiddenProbeUser == "" {
|
||||||
|
hiddenProbeUser = u
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
@ -311,28 +417,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
// watched list aus DB
|
// watched list aus DB
|
||||||
// bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben
|
// bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben
|
||||||
watched := store.ListWatchedLite("")
|
watched := store.ListWatchedLite("")
|
||||||
|
|
||||||
watchedByUser := map[string]WatchedModelLite{}
|
watchedByUser := map[string]WatchedModelLite{}
|
||||||
|
watchedOrder := make([]string, 0, len(watched))
|
||||||
|
|
||||||
for _, m := range watched {
|
for _, m := range watched {
|
||||||
if !m.Watching {
|
if !m.Watching {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// nur Chaturbate-Einträge berücksichtigen
|
|
||||||
host := strings.ToLower(strings.TrimSpace(m.Host))
|
host := strings.ToLower(strings.TrimSpace(m.Host))
|
||||||
host = strings.TrimPrefix(host, "www.")
|
host = strings.TrimPrefix(host, "www.")
|
||||||
|
|
||||||
if host != "" && host != "chaturbate.com" {
|
if host != "" && host != "chaturbate.com" {
|
||||||
// Falls Host gesetzt und nicht chaturbate -> ignorieren
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Falls Host leer ist, versuchen wir über Input/ModelKey zu erkennen
|
|
||||||
key := watchedChaturbateUserKey(m)
|
key := watchedChaturbateUserKey(m)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn Host leer ist, aber Input eindeutig nicht Chaturbate ist -> skip
|
|
||||||
if host == "" {
|
if host == "" {
|
||||||
in := strings.TrimSpace(m.Input)
|
in := strings.TrimSpace(m.Input)
|
||||||
if in != "" {
|
if in != "" {
|
||||||
@ -345,36 +450,84 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, exists := watchedByUser[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
watchedByUser[key] = m
|
watchedByUser[key] = m
|
||||||
|
watchedOrder = append(watchedOrder, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queue prune
|
// queue prune
|
||||||
nextQueue := queue[:0]
|
nextQueue := queue[:0]
|
||||||
nextQueued := map[string]bool{}
|
nextQueued := map[string]bool{}
|
||||||
|
|
||||||
for _, it := range queue {
|
for _, it := range queue {
|
||||||
m, ok := watchedByUser[it.userKey]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if runningByUser[it.userKey] != nil {
|
if runningByUser[it.userKey] != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
it.url = resolveChaturbateURL(m)
|
if it.source == "manual" {
|
||||||
if it.url == "" {
|
m, ok := manualByUser[it.userKey]
|
||||||
continue
|
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)
|
nextQueue = append(nextQueue, it)
|
||||||
nextQueued[it.userKey] = true
|
nextQueued[it.userKey] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
queue = nextQueue
|
queue = nextQueue
|
||||||
queued = nextQueued
|
queued = nextQueued
|
||||||
|
|
||||||
now := time.Now()
|
blindQueued := false
|
||||||
nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser))
|
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)
|
u := resolveChaturbateURL(m)
|
||||||
if u == "" {
|
if u == "" {
|
||||||
continue
|
continue
|
||||||
@ -385,7 +538,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
switch show {
|
switch show {
|
||||||
case "public":
|
case "public":
|
||||||
// public => kein Pending-Eintrag
|
|
||||||
if runningByUser[user] != nil {
|
if runningByUser[user] != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -399,11 +551,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
queue = append(queue, autoStartItem{
|
queue = append(queue, autoStartItem{
|
||||||
userKey: user,
|
userKey: user,
|
||||||
url: u,
|
url: u,
|
||||||
|
blind: false,
|
||||||
|
source: "watched",
|
||||||
})
|
})
|
||||||
queued[user] = true
|
queued[user] = true
|
||||||
|
|
||||||
case "private", "hidden", "away":
|
case "private", "hidden", "away":
|
||||||
// laufenden watched-Download beenden und auf public warten
|
|
||||||
nextPending = append(nextPending, PendingAutoStartItem{
|
nextPending = append(nextPending, PendingAutoStartItem{
|
||||||
ModelKey: user,
|
ModelKey: user,
|
||||||
URL: u,
|
URL: u,
|
||||||
@ -418,19 +571,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
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 {
|
if runningByUser[user] != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -441,16 +581,127 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
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{
|
queue = append(queue, autoStartItem{
|
||||||
userKey: user,
|
userKey: user,
|
||||||
url: u,
|
url: u,
|
||||||
|
blind: false,
|
||||||
|
source: "manual",
|
||||||
})
|
})
|
||||||
queued[user] = true
|
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()
|
pendingAutoStartMu.Lock()
|
||||||
_ = saveWatchedPendingAutoStartItems(nextPending)
|
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending)
|
||||||
pendingAutoStartMu.Unlock()
|
pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
case <-startTicker.C:
|
case <-startTicker.C:
|
||||||
@ -471,15 +722,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
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)
|
rooms, _, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge)
|
||||||
if !fresh {
|
if !fresh {
|
||||||
continue
|
continue
|
||||||
@ -494,10 +736,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it := queue[0]
|
||||||
show := strings.TrimSpace(showByUser[it.userKey])
|
show := strings.TrimSpace(showByUser[it.userKey])
|
||||||
isPublic := strings.Contains(show, "public")
|
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()
|
lastTry[it.userKey] = time.Now()
|
||||||
|
|
||||||
job, err := startRecordingInternal(RecordRequest{
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
@ -511,16 +766,24 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingAutoStartMu.Lock()
|
|
||||||
_ = removeWatchedPendingAutoStartItem(it.userKey)
|
|
||||||
pendingAutoStartMu.Unlock()
|
|
||||||
|
|
||||||
if verboseLogs() {
|
if verboseLogs() {
|
||||||
fmt.Println("▶️ [autostart] started:", it.url)
|
fmt.Println("▶️ [autostart] started:", it.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
if job != nil {
|
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 {
|
} else {
|
||||||
lastBlindTry[it.userKey] = time.Now()
|
lastBlindTry[it.userKey] = time.Now()
|
||||||
@ -541,7 +804,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
fmt.Println("▶️ [autostart] blind try started:", it.url)
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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".
|
// 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.
|
// Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
|
||||||
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
||||||
@ -15,85 +96,344 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
return
|
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
|
const cooldown = 2 * time.Minute
|
||||||
|
|
||||||
// wie lange wir nach Start warten, ob eine Output-Datei entsteht
|
|
||||||
const outputProbeMax = 20 * time.Second
|
const outputProbeMax = 20 * time.Second
|
||||||
|
|
||||||
// kleine Pause zwischen Starts, damit nicht zu viele Jobs auf einmal anlaufen
|
queue := make([]autoStartItem, 0, 64)
|
||||||
const startGap = 1200 * time.Millisecond
|
queued := map[string]bool{}
|
||||||
|
|
||||||
lastAttempt := map[string]time.Time{}
|
lastAttempt := map[string]time.Time{}
|
||||||
|
probeCursor := 0
|
||||||
|
|
||||||
tick := time.NewTicker(6 * time.Second)
|
scanTicker := time.NewTicker(scanInterval)
|
||||||
defer tick.Stop()
|
startTicker := time.NewTicker(startInterval)
|
||||||
|
defer scanTicker.Stop()
|
||||||
|
defer startTicker.Stop()
|
||||||
|
|
||||||
for range tick.C {
|
for {
|
||||||
// global früh abbrechen
|
select {
|
||||||
if isAutostartPaused() {
|
case <-scanTicker.C:
|
||||||
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
|
|
||||||
if isAutostartPaused() {
|
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 {
|
if !getSettings().UseMyFreeCamsWatcher {
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
|
if len(queue) == 0 {
|
||||||
u := strings.TrimSpace(m.Input)
|
|
||||||
if u == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
modelID := strings.TrimSpace(m.ID)
|
// Nur EIN Hidden-Probe gleichzeitig
|
||||||
if modelID == "" {
|
if hasHiddenProbeRunningForProvider("mfc") {
|
||||||
// Fallback
|
|
||||||
modelID = strings.TrimSpace(m.Host) + ":" + strings.TrimSpace(m.ModelKey)
|
|
||||||
}
|
|
||||||
if modelID == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown
|
it := queue[0]
|
||||||
if t, ok := lastAttempt[modelID]; ok && time.Since(t) < cooldown {
|
queue = queue[1:]
|
||||||
|
delete(queued, it.userKey)
|
||||||
|
|
||||||
|
if isJobRunningForURL(it.url) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bereits aktiv?
|
lastAttempt[it.userKey] = time.Now()
|
||||||
if isJobRunningForURL(u) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lastAttempt[modelID] = time.Now()
|
|
||||||
|
|
||||||
job, err := startRecordingInternal(RecordRequest{
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
URL: u,
|
URL: it.url,
|
||||||
Hidden: true,
|
Hidden: true,
|
||||||
})
|
})
|
||||||
if err != nil || job == nil {
|
if err != nil || job == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn keine echte Output-Datei entsteht -> Job wieder abbrechen/aufräumen
|
if it.source == "manual" {
|
||||||
go mfcAbortIfNoOutput(job.ID, outputProbeMax)
|
go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() {
|
||||||
|
pendingAutoStartMu.Lock()
|
||||||
time.Sleep(startGap)
|
_ = 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.
|
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
|
||||||
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
|
// 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)
|
deadline := time.Now().Add(maxWait)
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@ -142,7 +482,10 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
|
|||||||
// Output schon da?
|
// Output schon da?
|
||||||
if out != "" {
|
if out != "" {
|
||||||
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
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)
|
publishJob(jobID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@ -74,10 +75,152 @@ func safeUserKeyForFile(v string) string {
|
|||||||
return re.ReplaceAllString(v, "_")
|
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 {
|
func pendingAutoStartFilePath(userKey string) string {
|
||||||
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
|
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) {
|
func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) {
|
||||||
path := pendingAutoStartFilePath(userKey)
|
path := pendingAutoStartFilePath(userKey)
|
||||||
|
|
||||||
@ -133,36 +276,60 @@ func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, er
|
|||||||
return nil, err
|
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 {
|
for _, it := range globalItems {
|
||||||
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
||||||
if k == "" {
|
if k == "" {
|
||||||
continue
|
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 {
|
for _, it := range userItems {
|
||||||
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
k := strings.ToLower(strings.TrimSpace(it.ModelKey))
|
||||||
if k == "" {
|
if k == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
byKey[k] = it
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]PendingAutoStartItem, 0, len(byKey))
|
if idx, ok := indexByKey[k]; ok {
|
||||||
for _, it := range byKey {
|
out[idx] = it
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
indexByKey[k] = len(out)
|
||||||
out = append(out, it)
|
out = append(out, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error {
|
func saveWatchedPendingAutoStartItemsForProvider(provider string, items []PendingAutoStartItem) error {
|
||||||
next := make([]PendingAutoStartItem, 0, len(items))
|
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 {
|
for _, it := range items {
|
||||||
it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey))
|
it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey))
|
||||||
it.URL = strings.TrimSpace(it.URL)
|
it.URL = strings.TrimSpace(it.URL)
|
||||||
@ -181,9 +348,39 @@ func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error {
|
|||||||
return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next)
|
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))
|
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
|
||||||
if modelKey == "" {
|
|
||||||
|
if provider == "" || modelKey == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +391,9 @@ func removeWatchedPendingAutoStartItem(modelKey string) error {
|
|||||||
|
|
||||||
next := make([]PendingAutoStartItem, 0, len(items))
|
next := make([]PendingAutoStartItem, 0, len(items))
|
||||||
for _, it := range 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
|
continue
|
||||||
}
|
}
|
||||||
next = append(next, it)
|
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/delete-many", handleDeleteMany)
|
||||||
api.HandleFunc("/api/record/keep-many", handleKeepMany)
|
api.HandleFunc("/api/record/keep-many", handleKeepMany)
|
||||||
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
|
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
|
||||||
|
mux.HandleFunc("/api/record/release-file", handleReleaseFileTasks)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
@ -50,6 +50,10 @@ func main() {
|
|||||||
store := registerRoutes(mux, auth)
|
store := registerRoutes(mux, auth)
|
||||||
setChaturbateOnlineModelStore(store)
|
setChaturbateOnlineModelStore(store)
|
||||||
|
|
||||||
|
if err := clearAllPendingAutoStartOnStartup(); err != nil {
|
||||||
|
fmt.Println("⚠️ [pending-autostart] startup clear failed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Hintergrund-Worker
|
// Hintergrund-Worker
|
||||||
go startChaturbateOnlinePoller(store)
|
go startChaturbateOnlinePoller(store)
|
||||||
go startChaturbateAutoStartWorker(store)
|
go startChaturbateAutoStartWorker(store)
|
||||||
|
|||||||
@ -201,7 +201,7 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
st.Error = "Abgebrochen"
|
st.Error = "Abgebrochen"
|
||||||
} else {
|
} else {
|
||||||
st.Error = err.Error()
|
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()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
if err != nil || strings.TrimSpace(doneAbs) == "" {
|
||||||
@ -409,6 +420,9 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
|
publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta")
|
||||||
|
|
||||||
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
|
okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL)
|
||||||
|
if abortIfCanceled(merr, it.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if merr != nil || !okMeta {
|
if merr != nil || !okMeta {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen")
|
||||||
} else {
|
} else {
|
||||||
@ -430,6 +444,9 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
|
publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild")
|
||||||
|
|
||||||
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil)
|
||||||
|
if abortIfCanceled(terr, it.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).ThumbReady {
|
} else if finishedPhaseTruthForID(id).ThumbReady {
|
||||||
@ -456,6 +473,9 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
|
publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser")
|
||||||
|
|
||||||
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
|
genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL)
|
||||||
|
if abortIfCanceled(terr, it.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).TeaserReady {
|
} else if finishedPhaseTruthForID(id).TeaserReady {
|
||||||
@ -482,6 +502,9 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites")
|
||||||
|
|
||||||
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
|
_, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL)
|
||||||
|
if abortIfCanceled(serr, it.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).SpritesReady {
|
} else if finishedPhaseTruthForID(id).SpritesReady {
|
||||||
@ -505,6 +528,9 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse")
|
||||||
|
|
||||||
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
|
_, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw")
|
||||||
|
if abortIfCanceled(aerr, it.name) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen")
|
||||||
} else if finishedPhaseTruthForID(id).AnalyzeReady {
|
} else if finishedPhaseTruthForID(id).AnalyzeReady {
|
||||||
|
|||||||
@ -29,9 +29,11 @@ type cleanupResp struct {
|
|||||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||||
ErrorCount int `json:"errorCount"`
|
ErrorCount int `json:"errorCount"`
|
||||||
|
|
||||||
// ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen)
|
// Generated-GC
|
||||||
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
||||||
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
|
GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"`
|
||||||
|
GeneratedOrphansRemovedBytes int64 `json:"generatedOrphansRemovedBytes"`
|
||||||
|
GeneratedOrphansRemovedBytesHuman string `json:"generatedOrphansRemovedBytesHuman"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: falls du später Threshold per Body überschreiben willst.
|
// Optional: falls du später Threshold per Body überschreiben willst.
|
||||||
@ -103,11 +105,16 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
gcStats := triggerGeneratedGarbageCollectorSync()
|
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||||
resp.GeneratedOrphansChecked = gcStats.Checked
|
resp.GeneratedOrphansChecked = gcStats.Checked
|
||||||
resp.GeneratedOrphansRemoved = gcStats.Removed
|
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)
|
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||||
|
|
||||||
orphansTotalRemoved := resp.GeneratedOrphansRemoved
|
setCleanupTaskDone(
|
||||||
setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved))
|
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||||
|
)
|
||||||
|
|
||||||
time.AfterFunc(7*time.Second, func() {
|
time.AfterFunc(7*time.Second, func() {
|
||||||
clearCleanupTaskState()
|
clearCleanupTaskState()
|
||||||
@ -116,6 +123,15 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, resp)
|
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) {
|
func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
||||||
isCandidate := func(name string) bool {
|
isCandidate := func(name string) bool {
|
||||||
low := strings.ToLower(name)
|
low := strings.ToLower(name)
|
||||||
@ -201,38 +217,76 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
|
|||||||
|
|
||||||
resp.ScannedFiles = len(candidates)
|
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 {
|
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 {
|
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
|
||||||
continue
|
resp.DeletedFiles++
|
||||||
}
|
resp.DeletedBytes += c.size
|
||||||
|
|
||||||
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
|
if strings.TrimSpace(id) != "" {
|
||||||
id := stripHotPrefix(base)
|
removeGeneratedForID(id)
|
||||||
|
}
|
||||||
|
|
||||||
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
|
purgeDurationCacheForPath(c.path)
|
||||||
resp.DeletedFiles++
|
} else {
|
||||||
resp.DeletedBytes += c.size
|
resp.ErrorCount++
|
||||||
|
|
||||||
if strings.TrimSpace(id) != "" {
|
|
||||||
removeGeneratedForID(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
purgeDurationCacheForPath(c.path)
|
|
||||||
} else {
|
|
||||||
resp.ErrorCount++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCleanupTaskProgress(
|
||||||
|
i+1,
|
||||||
|
resp.ScannedFiles,
|
||||||
|
"",
|
||||||
|
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var generatedGCRunning int32
|
var generatedGCRunning int32
|
||||||
|
|
||||||
type generatedGCStats struct {
|
type generatedGCStats struct {
|
||||||
Checked int
|
Checked int
|
||||||
Removed 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).
|
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
|
||||||
@ -338,6 +392,7 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
|
|
||||||
removedMeta := 0
|
removedMeta := 0
|
||||||
checkedMeta := 0
|
checkedMeta := 0
|
||||||
|
removedBytes := int64(0)
|
||||||
|
|
||||||
if entries, err := os.ReadDir(metaRoot); err == nil {
|
if entries, err := os.ReadDir(metaRoot); err == nil {
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@ -354,14 +409,22 @@ func runGeneratedGarbageCollector() generatedGCStats {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
orphanDir := filepath.Join(metaRoot, id)
|
||||||
|
orphanBytes := dirSizeBytes(orphanDir)
|
||||||
|
|
||||||
removeGeneratedForID(id)
|
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.Checked += checkedMeta
|
||||||
stats.Removed += removedMeta
|
stats.Removed += removedMeta
|
||||||
|
stats.RemovedBytes += removedBytes
|
||||||
|
|
||||||
return stats
|
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
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ParsedModel = {
|
|
||||||
input: string
|
|
||||||
isUrl: boolean
|
|
||||||
host?: string
|
|
||||||
path?: string
|
|
||||||
modelKey: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChaturbateOnlineRoomItem = {
|
type ChaturbateOnlineRoomItem = {
|
||||||
username?: string
|
username?: string
|
||||||
current_show?: string
|
current_show?: string
|
||||||
@ -1564,15 +1556,6 @@ export default function App() {
|
|||||||
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
|
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
|
||||||
chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').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>('')
|
const lastClipboardUrlRef = useRef<string>('')
|
||||||
|
|
||||||
// --- START QUEUE (parallel) ---
|
// --- 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 = {
|
type StartQueueItem = {
|
||||||
url: string
|
url: string
|
||||||
silent: boolean
|
silent: boolean
|
||||||
pendingKeyLower?: string
|
pendingKeyLower?: string
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
|
source?: PendingAutoStartSource
|
||||||
}
|
}
|
||||||
|
|
||||||
const startQueueRef = useRef<StartQueueItem[]>([])
|
const startQueueRef = useRef<StartQueueItem[]>([])
|
||||||
@ -1808,9 +1792,14 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden })
|
const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden })
|
||||||
|
|
||||||
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
|
if (next.pendingKeyLower && next.source === 'manual') {
|
||||||
if (ok && next.pendingKeyLower) {
|
if (ok && !next.hidden) {
|
||||||
removePendingAutoStart(next.pendingKeyLower)
|
removePendingAutoStart(next.pendingKeyLower)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
removePendingAutoStart(next.pendingKeyLower)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// dedupe wieder freigeben
|
// dedupe wieder freigeben
|
||||||
@ -2146,50 +2135,7 @@ export default function App() {
|
|||||||
doneSortRef.current = doneSort
|
doneSortRef.current = doneSort
|
||||||
}, [doneSort])
|
}, [doneSort])
|
||||||
|
|
||||||
const applyPendingRoomSnapshot = useCallback(
|
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
|
||||||
(
|
|
||||||
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)
|
|
||||||
const modelsByKeyRef = useRef(modelsByKey)
|
const modelsByKeyRef = useRef(modelsByKey)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
modelsByKeyRef.current = modelsByKey
|
modelsByKeyRef.current = modelsByKey
|
||||||
@ -2253,173 +2199,14 @@ export default function App() {
|
|||||||
}, [pendingAutoStartByKey, modelsByKey])
|
}, [pendingAutoStartByKey, modelsByKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recSettings.useChaturbateApi) {
|
if (!authed) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
const id = window.setInterval(() => {
|
||||||
let timer: number | null = null
|
void loadPendingAutoStarts()
|
||||||
let inFlight = false
|
}, document.hidden ? 8000 : 3000)
|
||||||
|
|
||||||
const tick = async () => {
|
return () => window.clearInterval(id)
|
||||||
if (cancelled || inFlight) return
|
}, [authed, loadPendingAutoStarts])
|
||||||
|
|
||||||
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,
|
|
||||||
])
|
|
||||||
|
|
||||||
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
||||||
|
|
||||||
@ -2568,107 +2355,25 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
if (alreadyRunning) return true
|
if (alreadyRunning) return true
|
||||||
|
|
||||||
// Chaturbate-Startlogik mit Online-API:
|
// Backend-only Autostart:
|
||||||
// public -> direkt starten
|
// Frontend speichert nur pending, Backend startet eigenständig.
|
||||||
// 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 }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
const keyLower = providerKeyLowerFromUrl(norm)
|
||||||
|
|
||||||
if (mkLower) {
|
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
|
||||||
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
|
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
||||||
let resolvedImageUrl: string | undefined
|
mode: 'probe_retry',
|
||||||
let resolvedChatRoomUrl: string | undefined
|
source: 'manual',
|
||||||
let foundInApi = false
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) {
|
||||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
||||||
method: 'POST',
|
mode: 'probe_retry',
|
||||||
cache: 'no-store',
|
source: 'manual',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
})
|
||||||
body: JSON.stringify({
|
return true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -3833,8 +3538,7 @@ export default function App() {
|
|||||||
if (autoAddEnabled) setSourceUrl(url)
|
if (autoAddEnabled) setSourceUrl(url)
|
||||||
|
|
||||||
if (autoStartEnabled) {
|
if (autoStartEnabled) {
|
||||||
// ✅ immer enqueue (dedupe verhindert doppelt)
|
void startUrl(url, { silent: false })
|
||||||
enqueueStart({ url, silent: false })
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -3866,7 +3570,7 @@ export default function App() {
|
|||||||
window.removeEventListener('focus', kick)
|
window.removeEventListener('focus', kick)
|
||||||
document.removeEventListener('visibilitychange', kick)
|
document.removeEventListener('visibilitychange', kick)
|
||||||
}
|
}
|
||||||
}, [autoAddEnabled, autoStartEnabled, enqueueStart])
|
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||||||
@ -4187,8 +3891,6 @@ export default function App() {
|
|||||||
key={[
|
key={[
|
||||||
String((playerJob as any)?.id ?? ''),
|
String((playerJob as any)?.id ?? ''),
|
||||||
baseName(playerJob.output || ''),
|
baseName(playerJob.output || ''),
|
||||||
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
|
|
||||||
String(assetNonce),
|
|
||||||
].join('::')}
|
].join('::')}
|
||||||
job={playerJob}
|
job={playerJob}
|
||||||
modelKey={playerModelKey ?? undefined}
|
modelKey={playerModelKey ?? undefined}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type Props = {
|
|||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
watching?: boolean
|
watching?: boolean
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
roomStatusByModelKey?: Record<string, 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 pendingUrl = (p: PendingWatchedRoom) => {
|
||||||
const anyP = p as any
|
const anyP = p as any
|
||||||
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
|
return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? ''
|
||||||
@ -119,6 +127,27 @@ const modelKeyFromJob = (job: RecordJob): string => {
|
|||||||
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
|
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 = (
|
const roomStatusOfJob = (
|
||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
roomStatusByModelKey?: Record<string, string>,
|
roomStatusByModelKey?: Record<string, string>,
|
||||||
@ -578,14 +607,16 @@ export default function Downloads({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeLiveJobs = jobs.filter((j) => {
|
const activePreviewJobs = jobs.filter((j) => {
|
||||||
if ((j as any).endedAt) return false
|
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
||||||
return String(j.status ?? '').trim().toLowerCase() === 'running'
|
const status = String(j.status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording')
|
||||||
}).length
|
}).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(() => {
|
const id = window.setInterval(() => {
|
||||||
setThumbTick((t) => t + 1)
|
setThumbTick((t) => t + 1)
|
||||||
@ -940,6 +971,9 @@ export default function Downloads({
|
|||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
if (r.kind === 'job') {
|
if (r.kind === 'job') {
|
||||||
const j = r.job
|
const j = r.job
|
||||||
|
const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
|
||||||
|
const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
|
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
|
||||||
<LazyMountWhenVisible
|
<LazyMountWhenVisible
|
||||||
@ -948,8 +982,9 @@ export default function Downloads({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
live={false}
|
initialSrc={onlineImageUrl || providerFallbackSrc}
|
||||||
thumbTick={thumbTick}
|
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
|
||||||
|
enableHoverLive={true}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={effectiveRoomStatusOfJob(
|
roomStatus={effectiveRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
@ -957,7 +992,7 @@ export default function Downloads({
|
|||||||
modelsByKey,
|
modelsByKey,
|
||||||
growingByJobId
|
growingByJobId
|
||||||
)}
|
)}
|
||||||
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
|
fallbackSrc={providerFallbackSrc}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</LazyMountWhenVisible>
|
</LazyMountWhenVisible>
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type ModelFlags = {
|
|||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
watching?: boolean
|
watching?: boolean
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -125,6 +126,27 @@ const modelKeyFromJob = (job: RecordJob): string => {
|
|||||||
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
|
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 = (
|
const roomStatusOfJob = (
|
||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
roomStatusByModelKey?: Record<string, string>,
|
roomStatusByModelKey?: Record<string, string>,
|
||||||
@ -638,6 +660,13 @@ export default function DownloadsCardRow({
|
|||||||
const isLiked = flags?.liked === true
|
const isLiked = flags?.liked === true
|
||||||
const isWatching = Boolean(flags?.watching)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
@ -674,8 +703,12 @@ export default function DownloadsCardRow({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
live={true}
|
initialSrc={
|
||||||
thumbTick={thumbTick}
|
onlineImageUrlOfJob(j, modelsByKey) ||
|
||||||
|
`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`
|
||||||
|
}
|
||||||
|
refreshKey={shouldRefreshPreview(j) ? thumbTick : undefined}
|
||||||
|
enableHoverLive={false}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={previewRoomStatusOfJob(
|
roomStatus={previewRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
|
|||||||
@ -338,6 +338,10 @@ function looksLikeFileInUseError(err: unknown): boolean {
|
|||||||
s.includes('sharing violation') ||
|
s.includes('sharing violation') ||
|
||||||
s.includes('used by another process') ||
|
s.includes('used by another process') ||
|
||||||
s.includes('file in use') ||
|
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')
|
s.includes('409')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1436,6 +1440,11 @@ export default function FinishedDownloads({
|
|||||||
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
|
const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState<number>(1)
|
||||||
const [galleryColumnCount, setGalleryColumnCount] = 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 [tagFilter, setTagFilter] = React.useState<string[]>([])
|
||||||
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
const [searchQuery, setSearchQuery] = React.useState<string>('')
|
||||||
|
|
||||||
@ -1540,7 +1549,7 @@ export default function FinishedDownloads({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const searchActiveForGlobalFetch =
|
const searchActiveForGlobalFetch =
|
||||||
activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2)
|
activeTagSet.size > 0 || searchTokens.length > 0
|
||||||
|
|
||||||
const globalFilterActive = searchActiveForGlobalFetch
|
const globalFilterActive = searchActiveForGlobalFetch
|
||||||
const effectiveAllMode = globalFilterActive || loadMode === 'all'
|
const effectiveAllMode = globalFilterActive || loadMode === 'all'
|
||||||
@ -1552,8 +1561,15 @@ export default function FinishedDownloads({
|
|||||||
? galleryPageSize
|
? galleryPageSize
|
||||||
: pageSize
|
: pageSize
|
||||||
|
|
||||||
const doneJobsPage = doneJobs
|
const doneJobsPage =
|
||||||
const doneTotalPage = doneTotal
|
globalFilterActive
|
||||||
|
? (allDoneJobs ?? doneJobs)
|
||||||
|
: doneJobs
|
||||||
|
|
||||||
|
const doneTotalPage =
|
||||||
|
globalFilterActive
|
||||||
|
? (allDoneJobs?.length ?? doneTotal)
|
||||||
|
: doneTotal
|
||||||
|
|
||||||
const applyRenamedOutputLocal = useCallback(
|
const applyRenamedOutputLocal = useCallback(
|
||||||
(job: RecordJob): RecordJob => {
|
(job: RecordJob): RecordJob => {
|
||||||
@ -1888,6 +1904,30 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const clearTagFilter = useCallback(() => setTagFilter([]), [])
|
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(() => {
|
const flushResolutionsSoon = useCallback(() => {
|
||||||
if (resolutionsFlushTimerRef.current != null) return
|
if (resolutionsFlushTimerRef.current != null) return
|
||||||
resolutionsFlushTimerRef.current = window.setTimeout(() => {
|
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(
|
const withFileReleaseRetry = useCallback(
|
||||||
async <T,>(
|
async <T,>(
|
||||||
file: string,
|
file: string,
|
||||||
run: () => Promise<T>,
|
run: () => Promise<T>,
|
||||||
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
|
opts?: { close?: boolean; attempts?: number; baseDelayMs?: number }
|
||||||
): Promise<T> => {
|
): 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)
|
const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220)
|
||||||
|
|
||||||
let lastErr: unknown
|
let lastErr: unknown
|
||||||
|
let taskReleaseTriggered = false
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
@ -2174,10 +2236,25 @@ export default function FinishedDownloads({
|
|||||||
return await run()
|
return await run()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastErr = 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
|
throw e
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2185,7 +2262,7 @@ export default function FinishedDownloads({
|
|||||||
? lastErr
|
? lastErr
|
||||||
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
|
: new Error(String(lastErr ?? 'Unbekannter Fehler'))
|
||||||
},
|
},
|
||||||
[releasePlayingFile]
|
[releasePlayingFile, forceReleaseFileTasks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const runFileMutation = useCallback(
|
const runFileMutation = useCallback(
|
||||||
@ -2886,6 +2963,46 @@ export default function FinishedDownloads({
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// effects
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (view !== 'gallery') return
|
if (view !== 'gallery') return
|
||||||
|
|
||||||
@ -4098,7 +4215,7 @@ export default function FinishedDownloads({
|
|||||||
}}
|
}}
|
||||||
isSmall={isSmall}
|
isSmall={isSmall}
|
||||||
jobForDetails={jobForDetails}
|
jobForDetails={jobForDetails}
|
||||||
isLoading={false}
|
isLoading={allDoneJobsLoading}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
durations={durations}
|
durations={durations}
|
||||||
teaserState={teaserState}
|
teaserState={teaserState}
|
||||||
@ -4160,7 +4277,7 @@ export default function FinishedDownloads({
|
|||||||
{view === 'table' && (
|
{view === 'table' && (
|
||||||
<FinishedDownloadsTableView
|
<FinishedDownloadsTableView
|
||||||
rows={pageRows}
|
rows={pageRows}
|
||||||
isLoading={false}
|
isLoading={allDoneJobsLoading}
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
onToggleSelected={toggleSelected}
|
onToggleSelected={toggleSelected}
|
||||||
onToggleSelectAllPage={(checked) => {
|
onToggleSelectAllPage={(checked) => {
|
||||||
@ -4231,7 +4348,7 @@ export default function FinishedDownloads({
|
|||||||
if (checked) selectPageRows(pageRows)
|
if (checked) selectPageRows(pageRows)
|
||||||
else deselectPageRows(pageRows)
|
else deselectPageRows(pageRows)
|
||||||
}}
|
}}
|
||||||
isLoading={false}
|
isLoading={allDoneJobsLoading}
|
||||||
jobForDetails={jobForDetails}
|
jobForDetails={jobForDetails}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
durations={durations}
|
durations={durations}
|
||||||
|
|||||||
@ -765,7 +765,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
aria-label="Teaser mit Ton abspielen"
|
aria-label="Teaser mit Ton abspielen"
|
||||||
title="Ton einschalten"
|
title="Ton aktivieren"
|
||||||
>
|
>
|
||||||
<SpeakerWaveIcon className="size-3.5 shrink-0" />
|
<SpeakerWaveIcon className="size-3.5 shrink-0" />
|
||||||
<span>Ton aktivieren</span>
|
<span>Ton aktivieren</span>
|
||||||
|
|||||||
@ -351,6 +351,7 @@ export default function FinishedVideoPreview({
|
|||||||
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
|
const teaserMp4Ref = useRef<HTMLVideoElement | null>(null)
|
||||||
const clipsRef = useRef<HTMLVideoElement | null>(null)
|
const clipsRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const teaserSoundForcedRef = useRef(false)
|
const teaserSoundForcedRef = useRef(false)
|
||||||
|
const teaserMutedRef = useRef(muted)
|
||||||
|
|
||||||
const setTeaserPlaybackRate = useCallback((rate: number) => {
|
const setTeaserPlaybackRate = useCallback((rate: number) => {
|
||||||
const el = teaserMp4Ref.current
|
const el = teaserMp4Ref.current
|
||||||
@ -363,6 +364,24 @@ export default function FinishedVideoPreview({
|
|||||||
// ignore
|
// 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 playTeaserFromGesture = useCallback((withSound = true) => {
|
||||||
const el = teaserMp4Ref.current
|
const el = teaserMp4Ref.current
|
||||||
@ -817,7 +836,7 @@ export default function FinishedVideoPreview({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const teaserActivatedSoftly =
|
const teaserActivatedSoftly =
|
||||||
(animatedTrigger === 'hover' && hovered) || forceActive
|
animatedTrigger === 'hover' && hovered
|
||||||
|
|
||||||
const showTeaserLoadingOverlay =
|
const showTeaserLoadingOverlay =
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
@ -978,7 +997,7 @@ export default function FinishedVideoPreview({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveMuted = teaserSoundForcedRef.current ? false : muted
|
const effectiveMuted = teaserSoundForcedRef.current ? false : teaserMutedRef.current
|
||||||
|
|
||||||
applyInlineVideoPolicy(el, { muted: effectiveMuted })
|
applyInlineVideoPolicy(el, { muted: effectiveMuted })
|
||||||
|
|
||||||
@ -1103,7 +1122,7 @@ export default function FinishedVideoPreview({
|
|||||||
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
|
if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
|
||||||
window.clearInterval(watchdog)
|
window.clearInterval(watchdog)
|
||||||
}
|
}
|
||||||
}, [teaserActive, animatedMode, teaserSrc, muted, pageVisible, teaserSpeedHold, setTeaserPlaybackRate])
|
}, [teaserActive, animatedMode, teaserSrc, pageVisible])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activationNonce) return
|
if (!activationNonce) return
|
||||||
@ -1167,7 +1186,7 @@ export default function FinishedVideoPreview({
|
|||||||
window.clearTimeout(retryTimer)
|
window.clearTimeout(retryTimer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo, muted])
|
}, [activationNonce, teaserActive, animatedMode, showingInlineVideo])
|
||||||
|
|
||||||
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
|
// ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -13,14 +13,14 @@ import LoadingSpinner from './LoadingSpinner'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobId: string
|
jobId: string
|
||||||
modelKey?: string
|
enableHoverLive?: boolean
|
||||||
live?: boolean
|
refreshKey?: number
|
||||||
thumbTick?: number
|
|
||||||
blur?: boolean
|
blur?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
fit?: 'cover' | 'contain'
|
fit?: 'cover' | 'contain'
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
fallbackSrc?: string
|
fallbackSrc?: string
|
||||||
|
initialSrc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChaturbateCookieHeader(): string {
|
function buildChaturbateCookieHeader(): string {
|
||||||
@ -41,13 +41,14 @@ function buildChaturbateCookieHeader(): string {
|
|||||||
|
|
||||||
export default function ModelPreview({
|
export default function ModelPreview({
|
||||||
jobId,
|
jobId,
|
||||||
live = false,
|
enableHoverLive = false,
|
||||||
thumbTick,
|
refreshKey,
|
||||||
blur = false,
|
blur = false,
|
||||||
className,
|
className,
|
||||||
fit = 'cover',
|
fit = 'cover',
|
||||||
roomStatus,
|
roomStatus,
|
||||||
fallbackSrc,
|
fallbackSrc,
|
||||||
|
initialSrc,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [inView, setInView] = useState(false)
|
const [inView, setInView] = useState(false)
|
||||||
@ -82,17 +83,19 @@ export default function ModelPreview({
|
|||||||
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
|
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
|
||||||
|
|
||||||
const previewSrc = useMemo(() => {
|
const previewSrc = useMemo(() => {
|
||||||
|
const explicit = String(initialSrc ?? '').trim()
|
||||||
|
if (explicit) {
|
||||||
|
return explicit
|
||||||
|
}
|
||||||
|
|
||||||
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
|
||||||
|
|
||||||
if (!live) return base
|
if (typeof refreshKey === 'number') {
|
||||||
|
return `${base}&v=${refreshKey}`
|
||||||
// thumbTick kommt zentral vom Parent
|
|
||||||
if (typeof thumbTick === 'number') {
|
|
||||||
return `${base}&v=${thumbTick}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return base
|
return base
|
||||||
}, [jobId, live, thumbTick])
|
}, [initialSrc, jobId, refreshKey])
|
||||||
|
|
||||||
const fallbackImgSrc = useMemo(() => {
|
const fallbackImgSrc = useMemo(() => {
|
||||||
const s = String(fallbackSrc ?? '').trim()
|
const s = String(fallbackSrc ?? '').trim()
|
||||||
@ -100,16 +103,11 @@ export default function ModelPreview({
|
|||||||
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
||||||
}, [fallbackSrc, jobId])
|
}, [fallbackSrc, jobId])
|
||||||
|
|
||||||
const liveStreamSrc = useMemo(
|
|
||||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1`,
|
|
||||||
[jobId]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
|
|
||||||
const loadLive = async () => {
|
const loadLive = async () => {
|
||||||
if (!live || !hoverOpen) {
|
if (!enableHoverLive || !hoverOpen) {
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
@ -122,7 +120,6 @@ export default function ModelPreview({
|
|||||||
const cookieHeader = buildChaturbateCookieHeader()
|
const cookieHeader = buildChaturbateCookieHeader()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
|
|
||||||
const prepareRes = await fetch(
|
const prepareRes = await fetch(
|
||||||
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`,
|
`/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
|
if (ac.signal.aborted) return
|
||||||
|
|
||||||
// Auch wenn prepare fehlschlägt:
|
|
||||||
// best effort -> Stream-Endpunkt trotzdem versuchen
|
|
||||||
await prepareRes.json().catch(() => null)
|
await prepareRes.json().catch(() => null)
|
||||||
|
|
||||||
if (ac.signal.aborted) return
|
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 {
|
} catch {
|
||||||
if (ac.signal.aborted) return
|
if (ac.signal.aborted) return
|
||||||
|
setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`)
|
||||||
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!ac.signal.aborted) {
|
if (!ac.signal.aborted) setLivePreparing(false)
|
||||||
setLivePreparing(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadLive()
|
void loadLive()
|
||||||
|
return () => ac.abort()
|
||||||
return () => {
|
}, [enableHoverLive, hoverOpen, jobId])
|
||||||
ac.abort()
|
|
||||||
}
|
|
||||||
}, [hoverOpen, live, jobId, liveStreamSrc])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
@ -192,15 +179,18 @@ export default function ModelPreview({
|
|||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setHoverOpen(open)
|
const nextOpen = enableHoverLive && open
|
||||||
if (!open) {
|
|
||||||
|
setHoverOpen(nextOpen)
|
||||||
|
|
||||||
|
if (!nextOpen) {
|
||||||
setLiveSrc('')
|
setLiveSrc('')
|
||||||
setLivePreparing(false)
|
setLivePreparing(false)
|
||||||
setLiveReady(false)
|
setLiveReady(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
content={(open, { close }) =>
|
content={(open, { close }) =>
|
||||||
open && (
|
enableHoverLive && open ? (
|
||||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg overflow-hidden bg-black"
|
className="relative rounded-lg overflow-hidden bg-black"
|
||||||
@ -276,7 +266,7 @@ export default function ModelPreview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -923,7 +923,7 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [mounted, startMuted, isLive, metaReady, videoH, updateIntrinsicDims])
|
}, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
|
|||||||
@ -419,7 +419,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
...t,
|
...t,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
title: 'Aufräumen',
|
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),
|
done: Number(cleanup.done ?? 0),
|
||||||
total: Number(cleanup.total ?? 0),
|
total: Number(cleanup.total ?? 0),
|
||||||
err: undefined,
|
err: undefined,
|
||||||
@ -711,11 +711,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
const scannedFiles = Number(data.scannedFiles ?? 0)
|
const scannedFiles = Number(data.scannedFiles ?? 0)
|
||||||
|
const deletedFiles = Number(data.deletedFiles ?? 0)
|
||||||
const orphanRemoved = Number(data.orphanIdsRemoved ?? 0)
|
const deletedBytesHuman = String(data.deletedBytesHuman ?? '0 B')
|
||||||
const genRemoved = Number(data.generatedOrphansRemoved ?? 0)
|
|
||||||
|
|
||||||
const orphansTotalRemoved = orphanRemoved + genRemoved
|
|
||||||
|
|
||||||
setCleanupTask((t) => ({
|
setCleanupTask((t) => ({
|
||||||
...t,
|
...t,
|
||||||
@ -723,7 +720,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
done: scannedFiles,
|
done: scannedFiles,
|
||||||
total: scannedFiles,
|
total: scannedFiles,
|
||||||
title: 'Aufräumen',
|
title: 'Aufräumen',
|
||||||
text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`,
|
text: `gelöscht: ${deletedFiles}/${scannedFiles} · freigegeben: ${deletedBytesHuman}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
fadeOutTask(setCleanupTask, 7000, 500)
|
fadeOutTask(setCleanupTask, 7000, 500)
|
||||||
|
|||||||
@ -1683,7 +1683,12 @@ export default function VideoSplitModal({
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="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) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user