updated old version
This commit is contained in:
parent
078c60548c
commit
fb4f196994
@ -163,9 +163,10 @@ type EnsureAssetsResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EnsurePrimaryAssetsResult struct {
|
type EnsurePrimaryAssetsResult struct {
|
||||||
Skipped bool
|
Skipped bool
|
||||||
ThumbGenerated bool
|
ThumbGenerated bool
|
||||||
MetaOK bool
|
PreviewGenerated bool
|
||||||
|
MetaOK bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public wrappers (kompatibel zu deinem bisherigen API)
|
// Public wrappers (kompatibel zu deinem bisherigen API)
|
||||||
@ -229,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
|
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return out, perr
|
return out, perr
|
||||||
}
|
}
|
||||||
@ -254,17 +255,26 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Basis-Meta nur für Dauer/Props sicherstellen
|
previewBefore := func() bool {
|
||||||
|
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||||
out.MetaOK = meta.ok
|
out.MetaOK = meta.ok
|
||||||
|
|
||||||
if thumbBefore {
|
if thumbBefore && previewBefore {
|
||||||
out.Skipped = true
|
out.Skipped = true
|
||||||
progress(1)
|
progress(1)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const thumbsW = 1.0
|
const (
|
||||||
|
thumbsW = 0.25
|
||||||
|
previewW = 0.75
|
||||||
|
)
|
||||||
|
|
||||||
progress(0)
|
progress(0)
|
||||||
|
|
||||||
@ -318,8 +328,96 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
|||||||
progress(thumbsW)
|
progress(thumbsW)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = sourceInputInvalid
|
// ----------------
|
||||||
progress(1)
|
// Preview / Teaser
|
||||||
|
// ----------------
|
||||||
|
const (
|
||||||
|
previewClipLenSec = 0.75
|
||||||
|
previewMaxClips = 12
|
||||||
|
)
|
||||||
|
|
||||||
|
var computedPreviewClips []previewClip
|
||||||
|
|
||||||
|
if previewBefore {
|
||||||
|
progress(1)
|
||||||
|
} else {
|
||||||
|
func() {
|
||||||
|
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
progress(thumbsW + 0.02)
|
||||||
|
|
||||||
|
if err := genSem.Acquire(genCtx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer genSem.Release()
|
||||||
|
|
||||||
|
progress(thumbsW + 0.05)
|
||||||
|
|
||||||
|
if err := generateTeaserClipsMP4WithProgress(
|
||||||
|
genCtx,
|
||||||
|
videoPath,
|
||||||
|
previewPath,
|
||||||
|
previewClipLenSec,
|
||||||
|
previewMaxClips,
|
||||||
|
func(r float64) {
|
||||||
|
if r < 0 {
|
||||||
|
r = 0
|
||||||
|
}
|
||||||
|
if r > 1 {
|
||||||
|
r = 1
|
||||||
|
}
|
||||||
|
progress(thumbsW + r*previewW)
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
if isFFmpegInputInvalidError(err) {
|
||||||
|
sourceInputInvalid = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out.PreviewGenerated = true
|
||||||
|
|
||||||
|
if !(meta.durSec > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := TeaserPreviewOptions{
|
||||||
|
Segments: previewMaxClips,
|
||||||
|
SegmentDuration: previewClipLenSec,
|
||||||
|
}
|
||||||
|
|
||||||
|
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
|
||||||
|
|
||||||
|
clips := make([]previewClip, 0, len(starts))
|
||||||
|
for _, s := range starts {
|
||||||
|
clips = append(clips, previewClip{
|
||||||
|
StartSeconds: math.Round(s*1000) / 1000,
|
||||||
|
DurationSeconds: math.Round(segDur*1000) / 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
computedPreviewClips = clips
|
||||||
|
}()
|
||||||
|
|
||||||
|
progress(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta mit Preview-Clips aktualisieren (best effort)
|
||||||
|
if meta.durSec > 0 && !sourceInputInvalid {
|
||||||
|
_ = writeVideoMetaWithPreviewClipsAndSprite(
|
||||||
|
metaPath,
|
||||||
|
fi,
|
||||||
|
meta.durSec,
|
||||||
|
meta.vw,
|
||||||
|
meta.vh,
|
||||||
|
meta.fps,
|
||||||
|
meta.sourceURL,
|
||||||
|
computedPreviewClips,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,6 +454,82 @@ func ensureMetaForVideoCtx(
|
|||||||
return meta.ok, nil
|
return meta.ok, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureThumbForVideoCtx(
|
||||||
|
ctx context.Context,
|
||||||
|
videoPath string,
|
||||||
|
sourceURL string,
|
||||||
|
) (bool, error) {
|
||||||
|
videoPath = strings.TrimSpace(videoPath)
|
||||||
|
if videoPath == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(videoPath)
|
||||||
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
id := assetIDFromVideoPath(videoPath)
|
||||||
|
if id == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
|
||||||
|
if perr != nil {
|
||||||
|
return false, perr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schon vorhanden -> nichts zu tun
|
||||||
|
if tfi, err := os.Stat(thumbPath); err == nil && tfi != nil && !tfi.IsDir() && tfi.Size() > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta best effort sicherstellen, damit wir Duration für den "kurz vor Ende"-Fallback haben
|
||||||
|
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
|
||||||
|
|
||||||
|
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := thumbSem.Acquire(genCtx); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer thumbSem.Release()
|
||||||
|
|
||||||
|
// Immer letztes Frame bevorzugen
|
||||||
|
img, e1 := extractLastFrameJPG(videoPath)
|
||||||
|
if e1 != nil || len(img) == 0 {
|
||||||
|
// Fallback: kurz vor Ende
|
||||||
|
if meta.durSec > 0 {
|
||||||
|
t := meta.durSec - 0.25
|
||||||
|
if t < 0 {
|
||||||
|
t = 0
|
||||||
|
}
|
||||||
|
img, e1 = extractFrameAtTimeJPG(videoPath, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Letzter Fallback: erstes Frame
|
||||||
|
if e1 != nil || len(img) == 0 {
|
||||||
|
img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e1 != nil {
|
||||||
|
if isFFmpegInputInvalidError(e1) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, e1
|
||||||
|
}
|
||||||
|
if len(img) == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := atomicWriteFile(thumbPath, img); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureTeaserForVideoCtx(
|
func ensureTeaserForVideoCtx(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
videoPath string,
|
videoPath string,
|
||||||
@ -409,9 +583,14 @@ func ensureTeaserForVideoCtx(
|
|||||||
nil,
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if isFFmpegInputInvalidError(err) {
|
if isFFmpegInputInvalidError(err) {
|
||||||
return false, nil
|
return false, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err)
|
||||||
}
|
}
|
||||||
return false, err
|
return false, fmt.Errorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren
|
||||||
|
if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 {
|
||||||
|
return false, fmt.Errorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
var computedPreviewClips []previewClip
|
var computedPreviewClips []previewClip
|
||||||
|
|||||||
@ -5,6 +5,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -89,6 +90,96 @@ 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) {
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
jobsMu.RLock()
|
||||||
|
job := jobs[jobID]
|
||||||
|
status := JobStatus("")
|
||||||
|
out := ""
|
||||||
|
hidden := false
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
status = job.Status
|
||||||
|
out = strings.TrimSpace(job.Output)
|
||||||
|
hidden = job.Hidden
|
||||||
|
}
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
// Job schon weg oder nicht mehr running -> nichts tun
|
||||||
|
if job == nil || status != JobRunning {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// echte Output-Datei vorhanden?
|
||||||
|
if out != "" {
|
||||||
|
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
|
// hidden blind-try wird jetzt sichtbar gemacht
|
||||||
|
if hidden {
|
||||||
|
jobsMu.Lock()
|
||||||
|
j := jobs[jobID]
|
||||||
|
if j != nil {
|
||||||
|
j.Hidden = false
|
||||||
|
}
|
||||||
|
jobsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
publishJob(jobID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(900 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nach Wartezeit immer noch keine Datei => stoppen + löschen
|
||||||
|
jobsMu.Lock()
|
||||||
|
job := jobs[jobID]
|
||||||
|
if job == nil || job.Status != JobRunning {
|
||||||
|
jobsMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pc := job.previewCmd
|
||||||
|
job.previewCmd = nil
|
||||||
|
|
||||||
|
previewCancel := job.previewCancel
|
||||||
|
job.previewCancel = nil
|
||||||
|
|
||||||
|
recordCancel := job.cancel
|
||||||
|
out := strings.TrimSpace(job.Output)
|
||||||
|
wasVisible := !job.Hidden
|
||||||
|
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if previewCancel != nil {
|
||||||
|
previewCancel()
|
||||||
|
}
|
||||||
|
if pc != nil && pc.Process != nil {
|
||||||
|
_ = pc.Process.Kill()
|
||||||
|
}
|
||||||
|
if recordCancel != nil {
|
||||||
|
recordCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0-Byte Datei ggf. löschen
|
||||||
|
if out != "" {
|
||||||
|
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
|
||||||
|
_ = os.Remove(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
j := jobs[jobID]
|
||||||
|
delete(jobs, jobID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if wasVisible && j != nil {
|
||||||
|
publishJobRemove(j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Startet watched+online(public) automatisch – unabhängig vom Frontend
|
// Startet watched+online(public) automatisch – unabhängig vom Frontend
|
||||||
func startChaturbateAutoStartWorker(store *ModelStore) {
|
func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@ -99,26 +190,31 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pollInterval = 5 * time.Second
|
const pollInterval = 5 * time.Second
|
||||||
const startGap = 5 * time.Second
|
const startGap = 1500 * time.Millisecond
|
||||||
|
|
||||||
|
// normaler Retry für API-public
|
||||||
const retryCooldown = 25 * time.Second
|
const retryCooldown = 25 * time.Second
|
||||||
|
|
||||||
|
// aggressiver vermeiden für "nicht in API, aber watched" Blind-Try
|
||||||
|
const blindRetryCooldown = 2 * time.Minute
|
||||||
|
|
||||||
|
// wie lange wir warten, ob eine echte Datei entsteht
|
||||||
|
const outputProbeMax = 20 * time.Second
|
||||||
|
|
||||||
queue := make([]autoStartItem, 0, 64)
|
queue := make([]autoStartItem, 0, 64)
|
||||||
queued := map[string]bool{}
|
queued := map[string]bool{}
|
||||||
lastTry := map[string]time.Time{}
|
lastTry := map[string]time.Time{}
|
||||||
|
lastBlindTry := map[string]time.Time{}
|
||||||
|
|
||||||
var lastStart time.Time
|
var lastStart time.Time
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if isAutostartPaused() {
|
if isAutostartPaused() {
|
||||||
// optional: Queue behalten oder leeren – ich würde sie behalten.
|
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
|
|
||||||
// Backend-Autostart läuft, wenn
|
|
||||||
// - Chaturbate API aktiviert ist
|
|
||||||
// - Autostart NICHT pausiert ist (wird oben schon via isAutostartPaused() geprüft)
|
|
||||||
if !s.UseChaturbateAPI {
|
if !s.UseChaturbateAPI {
|
||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
@ -127,7 +223,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cookieHdr := cookieHeaderFromSettings(s)
|
cookieHdr := cookieHeaderFromSettings(s)
|
||||||
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
|
|
||||||
if !hasChaturbateCookies(cookieHdr) {
|
if !hasChaturbateCookies(cookieHdr) {
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
continue
|
continue
|
||||||
@ -139,11 +234,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
cbMu.RUnlock()
|
cbMu.RUnlock()
|
||||||
|
|
||||||
showByUser := map[string]string{}
|
showByUser := map[string]string{}
|
||||||
|
seenInAPI := map[string]bool{}
|
||||||
|
|
||||||
for _, r := range rooms {
|
for _, r := range rooms {
|
||||||
showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
u := normUser(r.Username)
|
||||||
|
if u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenInAPI[u] = true
|
||||||
|
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
||||||
}
|
}
|
||||||
|
|
||||||
// running users (damit wir nicht doppelt starten)
|
// laufende Jobs sammeln
|
||||||
running := map[string]bool{}
|
running := map[string]bool{}
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
for _, j := range jobs {
|
for _, j := range jobs {
|
||||||
@ -178,20 +280,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if running[it.userKey] {
|
if running[it.userKey] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
it.url = resolveChaturbateURL(m)
|
it.url = resolveChaturbateURL(m)
|
||||||
if it.url == "" {
|
if it.url == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist
|
|
||||||
nextQueue = append(nextQueue, it)
|
nextQueue = append(nextQueue, it)
|
||||||
nextQueued[it.userKey] = true
|
nextQueued[it.userKey] = true
|
||||||
}
|
}
|
||||||
queue = nextQueue
|
queue = nextQueue
|
||||||
queued = nextQueued
|
queued = nextQueued
|
||||||
|
|
||||||
// enqueue new public watched
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 1) watched + API sagt public => normal enqueue
|
||||||
|
// ------------------------------------------------------------
|
||||||
for user, m := range watchedByUser {
|
for user, m := range watchedByUser {
|
||||||
if showByUser[user] != "public" {
|
if showByUser[user] != "public" {
|
||||||
continue
|
continue
|
||||||
@ -210,18 +315,78 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if u == "" {
|
if u == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
queue = append(queue, autoStartItem{userKey: user, url: u})
|
|
||||||
|
queue = append(queue, autoStartItem{
|
||||||
|
userKey: user,
|
||||||
|
url: u,
|
||||||
|
})
|
||||||
queued[user] = true
|
queued[user] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// starte max. einen Job pro Loop (mit Abstand)
|
// ------------------------------------------------------------
|
||||||
|
// 2) watched + NICHT in API => blind best-effort try wie MFC
|
||||||
|
// aber nur wenn:
|
||||||
|
// - nicht schon running
|
||||||
|
// - nicht schon queued
|
||||||
|
// - nicht kürzlich versucht
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
for user, m := range watchedByUser {
|
||||||
|
if seenInAPI[user] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if running[user] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if queued[user] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
u := resolveChaturbateURL(m)
|
||||||
|
if u == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// direkt "blind" starten, nicht erst in queue hängen,
|
||||||
|
// damit das Verhalten wie MFC ist
|
||||||
|
if !lastStart.IsZero() && time.Since(lastStart) < startGap {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lastBlindTry[user] = time.Now()
|
||||||
|
|
||||||
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
|
URL: u,
|
||||||
|
Cookie: cookieHdr,
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
if err != nil || job == nil {
|
||||||
|
if verboseLogs() {
|
||||||
|
fmt.Println("❌ [autostart] blind start failed:", u, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if verboseLogs() {
|
||||||
|
fmt.Println("▶️ [autostart] blind try started:", u)
|
||||||
|
}
|
||||||
|
|
||||||
|
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
|
||||||
|
lastStart = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 3) normale API-public queue abarbeiten
|
||||||
|
// ------------------------------------------------------------
|
||||||
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
|
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
|
||||||
it := queue[0]
|
it := queue[0]
|
||||||
queue = queue[1:]
|
queue = queue[1:]
|
||||||
delete(queued, it.userKey)
|
delete(queued, it.userKey)
|
||||||
lastTry[it.userKey] = time.Now()
|
lastTry[it.userKey] = time.Now()
|
||||||
|
|
||||||
_, err := startRecordingInternal(RecordRequest{
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
URL: it.url,
|
URL: it.url,
|
||||||
Cookie: cookieHdr,
|
Cookie: cookieHdr,
|
||||||
})
|
})
|
||||||
@ -233,6 +398,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if verboseLogs() {
|
if verboseLogs() {
|
||||||
fmt.Println("▶️ [autostart] started:", it.url)
|
fmt.Println("▶️ [autostart] started:", it.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// optional auch hier absichern; schadet nicht
|
||||||
|
if job != nil {
|
||||||
|
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
|
||||||
|
}
|
||||||
|
|
||||||
lastStart = time.Now()
|
lastStart = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -92,25 +91,6 @@ var (
|
|||||||
cbRefreshInFlight bool
|
cbRefreshInFlight bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- HLS refresh throttling (damit /online nicht zu teuer wird) ---
|
|
||||||
var cbHlsRefreshMu sync.Mutex
|
|
||||||
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
|
|
||||||
|
|
||||||
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
|
|
||||||
if userLower == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cbHlsRefreshMu.Lock()
|
|
||||||
defer cbHlsRefreshMu.Unlock()
|
|
||||||
|
|
||||||
last := cbHlsRefreshAt[userLower]
|
|
||||||
if !last.IsZero() && time.Since(last) < minInterval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cbHlsRefreshAt[userLower] = time.Now()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeList(in []string) []string {
|
func normalizeList(in []string) []string {
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
out := make([]string, 0, len(in))
|
out := make([]string, 0, len(in))
|
||||||
@ -367,167 +347,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cbBioRefreshMu sync.Mutex
|
|
||||||
var cbBioRefreshAt = map[string]time.Time{} // key=userLower -> last biocontext refresh
|
|
||||||
|
|
||||||
func shouldRefreshBio(userLower string, minInterval time.Duration) bool {
|
|
||||||
if userLower == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
cbBioRefreshMu.Lock()
|
|
||||||
defer cbBioRefreshMu.Unlock()
|
|
||||||
|
|
||||||
last := cbBioRefreshAt[userLower]
|
|
||||||
if !last.IsZero() && time.Since(last) < minInterval {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
cbBioRefreshAt[userLower] = time.Now()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeChaturbateJobsMissingFromOnlineSnapshot() map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
|
|
||||||
cbMu.RLock()
|
|
||||||
lite := cb.LiteByUser
|
|
||||||
cbMu.RUnlock()
|
|
||||||
|
|
||||||
jobsMu.RLock()
|
|
||||||
defer jobsMu.RUnlock()
|
|
||||||
|
|
||||||
for _, j := range jobs {
|
|
||||||
if j == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !isActiveRecordingJob(j) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
userLower := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL)))
|
|
||||||
if userLower == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if lite != nil {
|
|
||||||
if _, ok := lite[userLower]; ok {
|
|
||||||
continue // schon im /online Snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie := strings.TrimSpace(j.PreviewCookie)
|
|
||||||
out[userLower] = cookie
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshChaturbateStatusFromBioContext(ctx context.Context, model string, cookieHeader string) error {
|
|
||||||
model = sanitizeModelKey(model)
|
|
||||||
if model == "" {
|
|
||||||
return fmt.Errorf("empty model")
|
|
||||||
}
|
|
||||||
|
|
||||||
u := fmt.Sprintf(chaturbateBioContextURLFmt, model)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
|
||||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
|
||||||
req.Header.Set("Referer", "https://chaturbate.com/"+model+"/")
|
|
||||||
|
|
||||||
if ck := strings.TrimSpace(cookieHeader); ck != "" {
|
|
||||||
req.Header.Set("Cookie", ck)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := cbHTTP.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
||||||
return fmt.Errorf("biocontext HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var bio any
|
|
||||||
if err := json.Unmarshal(raw, &bio); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchedAt := time.Now().UTC()
|
|
||||||
|
|
||||||
if cbModelStore != nil {
|
|
||||||
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
|
|
||||||
}
|
|
||||||
|
|
||||||
show, chatRoomURL, imageURL, ok := inferRoomStatusFromBio(bio)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isOnline := show != "offline" && show != "unknown"
|
|
||||||
|
|
||||||
if cbModelStore != nil {
|
|
||||||
_ = cbModelStore.SetChaturbateRoomState(
|
|
||||||
"chaturbate.com",
|
|
||||||
model,
|
|
||||||
show,
|
|
||||||
isOnline,
|
|
||||||
chatRoomURL,
|
|
||||||
imageURL,
|
|
||||||
fetchedAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func startChaturbateBioContextPoller() {
|
|
||||||
const scanEvery = 15 * time.Second
|
|
||||||
const minPerModel = 1 * time.Minute
|
|
||||||
|
|
||||||
ticker := time.NewTicker(scanEvery)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
if !getSettings().UseChaturbateAPI {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
missing := activeChaturbateJobsMissingFromOnlineSnapshot()
|
|
||||||
if len(missing) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for userLower, cookieHeader := range missing {
|
|
||||||
if !shouldRefreshBio(userLower, minPerModel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
go func(model, cookie string) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := refreshChaturbateStatusFromBioContext(ctx, model, cookie); err != nil {
|
|
||||||
fmt.Println("⚠️ [chaturbate] biocontext refresh failed:", model, err)
|
|
||||||
}
|
|
||||||
}(userLower, cookieHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Profilbild Download + Persist (online -> offline) ---
|
// --- Profilbild Download + Persist (online -> offline) ---
|
||||||
|
|
||||||
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
||||||
@ -850,140 +669,6 @@ func hashKey(parts ...string) string {
|
|||||||
return hex.EncodeToString(h.Sum(nil))
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// jobMatchesUser prüft, ob ein laufender Job zu diesem Username gehört.
|
|
||||||
// (wir matchen über SourceURL und Output-Pfad – robust genug ohne modelNameFromFilename Abhängigkeit)
|
|
||||||
func jobMatchesUser(j *RecordJob, userLower string) bool {
|
|
||||||
if j == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
u := strings.ToLower(strings.TrimSpace(userLower))
|
|
||||||
if u == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) SourceURL enthält meist /<username>
|
|
||||||
if s := strings.ToLower(strings.TrimSpace(j.SourceURL)); s != "" {
|
|
||||||
if strings.Contains(s, "/"+u) || strings.HasSuffix(s, "/"+u) || strings.HasSuffix(s, u) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Output-Pfad enthält bei dir häufig den modelKey im Dateinamen/Ordner
|
|
||||||
if out := strings.ToLower(strings.TrimSpace(j.Output)); out != "" {
|
|
||||||
base := strings.ToLower(strings.TrimSpace(filepath.Base(out)))
|
|
||||||
if strings.Contains(base, u) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
dir := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(out))))
|
|
||||||
if dir == u {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchCurrentBestHLS lädt die Room-Seite, parsed hls_source und wählt die beste Variant-Playlist.
|
|
||||||
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
|
|
||||||
u := strings.TrimSpace(username)
|
|
||||||
if u == "" {
|
|
||||||
return "", fmt.Errorf("empty username")
|
|
||||||
}
|
|
||||||
|
|
||||||
hc := NewHTTPClient(userAgent)
|
|
||||||
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
|
|
||||||
|
|
||||||
body, err := hc.FetchPage(ctx, pageURL, cookie)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
master, err := ParseStream(body) // hls_source
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSpace(master), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.
|
|
||||||
// Wenn die URL rotiert hat: stopPreview(job) damit ffmpeg neu startet.
|
|
||||||
func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua string) {
|
|
||||||
if strings.TrimSpace(userLower) == "" || strings.TrimSpace(newHls) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toStop := make([]*RecordJob, 0, 4)
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
|
||||||
for _, j := range jobs {
|
|
||||||
if j == nil || j.Status != JobRunning {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !jobMatchesUser(j, userLower) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
old := strings.TrimSpace(j.PreviewM3U8)
|
|
||||||
|
|
||||||
j.PreviewM3U8 = newHls
|
|
||||||
j.PreviewCookie = cookie
|
|
||||||
j.PreviewUA = ua
|
|
||||||
|
|
||||||
if old != "" && old != newHls {
|
|
||||||
j.PreviewState = ""
|
|
||||||
j.PreviewStateAt = ""
|
|
||||||
j.PreviewStateMsg = ""
|
|
||||||
toStop = append(toStop, j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobsMu.Unlock()
|
|
||||||
|
|
||||||
for _, j := range toStop {
|
|
||||||
stopPreview(j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshRunningJobsHLSAsync(users []string, liteByUser map[string]ChaturbateOnlineRoomLite, cookieHeader string, reqUA string) {
|
|
||||||
if len(users) == 0 || liteByUser == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hlsMinInterval = 12 * time.Second
|
|
||||||
|
|
||||||
liteCopy := make(map[string]ChaturbateOnlineRoomLite, len(liteByUser))
|
|
||||||
for k, v := range liteByUser {
|
|
||||||
liteCopy[k] = v
|
|
||||||
}
|
|
||||||
|
|
||||||
go func(usersCopy []string, rooms map[string]ChaturbateOnlineRoomLite, cookie string, ua string) {
|
|
||||||
for _, u := range usersCopy {
|
|
||||||
rm, ok := rooms[u]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
|
||||||
if show == "" || show == "offline" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shouldRefreshHLS(u, hlsMinInterval) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
||||||
newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookie, ua)
|
|
||||||
cancel()
|
|
||||||
if err != nil || strings.TrimSpace(newHls) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshRunningJobsHLS(u, newHls, cookie, ua)
|
|
||||||
}
|
|
||||||
}(append([]string(nil), users...), liteCopy, cookieHeader, reqUA)
|
|
||||||
}
|
|
||||||
|
|
||||||
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
@ -992,9 +677,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
enabled := getSettings().UseChaturbateAPI
|
enabled := getSettings().UseChaturbateAPI
|
||||||
|
|
||||||
// Optional: Cookie vom Frontend (für Cloudflare/session – best effort)
|
|
||||||
cookieHeader := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie"))
|
|
||||||
|
|
||||||
// UA vom Client (oder fallback)
|
// UA vom Client (oder fallback)
|
||||||
reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
|
reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
|
||||||
if reqUA == "" {
|
if reqUA == "" {
|
||||||
@ -1263,15 +945,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
needBootstrap = fetchedAt.IsZero()
|
needBootstrap = fetchedAt.IsZero()
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// ✅ HLS URL Refresh für laufende Jobs (best effort)
|
|
||||||
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
|
|
||||||
// und nur wenn User gerade online ist.
|
|
||||||
// ---------------------------
|
|
||||||
if onlySpecificUsers && liteByUser != nil {
|
|
||||||
refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Persist "last seen online/offline" für explizit angefragte User
|
// Persist "last seen online/offline" für explizit angefragte User
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
209
backend/live.go
209
backend/live.go
@ -5,6 +5,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -15,6 +16,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -39,6 +41,59 @@ import (
|
|||||||
// Allowed files that may be served out of PreviewDir.
|
// Allowed files that may be served out of PreviewDir.
|
||||||
var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts|init\.m4s|\w+\.m4s)$`)
|
var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts|init\.m4s|\w+\.m4s)$`)
|
||||||
|
|
||||||
|
var cbHlsRefreshMu sync.Mutex
|
||||||
|
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
|
||||||
|
|
||||||
|
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
|
||||||
|
userLower = strings.ToLower(strings.TrimSpace(userLower))
|
||||||
|
if userLower == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cbHlsRefreshMu.Lock()
|
||||||
|
defer cbHlsRefreshMu.Unlock()
|
||||||
|
|
||||||
|
last := cbHlsRefreshAt[userLower]
|
||||||
|
if !last.IsZero() && time.Since(last) < minInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cbHlsRefreshAt[userLower] = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchCurrentBestHLS lädt die Room-Seite und parsed hls_source.
|
||||||
|
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
|
||||||
|
u := strings.TrimSpace(username)
|
||||||
|
if u == "" {
|
||||||
|
return "", fmt.Errorf("empty username")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(userAgent) == "" {
|
||||||
|
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||||||
|
}
|
||||||
|
|
||||||
|
hc := NewHTTPClient(userAgent)
|
||||||
|
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
|
||||||
|
|
||||||
|
body, err := hc.FetchPage(ctx, pageURL, cookie)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
master, err := ParseStream(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
master = strings.TrimSpace(master)
|
||||||
|
if master == "" {
|
||||||
|
return "", fmt.Errorf("empty parsed hls url")
|
||||||
|
}
|
||||||
|
|
||||||
|
return master, nil
|
||||||
|
}
|
||||||
|
|
||||||
func serveLiveNotReady(w http.ResponseWriter, r *http.Request) {
|
func serveLiveNotReady(w http.ResponseWriter, r *http.Request) {
|
||||||
// ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries.
|
// ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries.
|
||||||
// Deshalb: IMMER 200 + gültige (aber leere) m3u8 zurückgeben.
|
// Deshalb: IMMER 200 + gültige (aber leere) m3u8 zurückgeben.
|
||||||
@ -103,16 +158,130 @@ func stopPreview(job *RecordJob) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
|
||||||
|
if job == nil {
|
||||||
|
return fmt.Errorf("job nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.RLock()
|
||||||
|
sourceURL := strings.TrimSpace(job.SourceURL)
|
||||||
|
cookie := strings.TrimSpace(job.PreviewCookie)
|
||||||
|
ua := strings.TrimSpace(job.PreviewUA)
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
if detectProvider(sourceURL) != "chaturbate" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Cookie vom aktuellen Request bevorzugen
|
||||||
|
if reqCookie := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")); reqCookie != "" {
|
||||||
|
cookie = reqCookie
|
||||||
|
}
|
||||||
|
|
||||||
|
// UA vom Request bevorzugen
|
||||||
|
if reqUA := strings.TrimSpace(r.Header.Get("User-Agent")); reqUA != "" {
|
||||||
|
ua = reqUA
|
||||||
|
}
|
||||||
|
if ua == "" {
|
||||||
|
ua = "Mozilla/5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(extractUsername(sourceURL))
|
||||||
|
if username == "" {
|
||||||
|
return fmt.Errorf("username not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ wichtig: doppelten Refresh verhindern
|
||||||
|
if !shouldRefreshHLS(strings.ToLower(username), 10*time.Second) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
|
defer cancelRefresh()
|
||||||
|
|
||||||
|
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newHls = strings.TrimSpace(newHls)
|
||||||
|
if newHls == "" {
|
||||||
|
return fmt.Errorf("empty hls")
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldHls string
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
oldHls = strings.TrimSpace(job.PreviewM3U8)
|
||||||
|
job.PreviewM3U8 = newHls
|
||||||
|
job.PreviewCookie = cookie
|
||||||
|
job.PreviewUA = ua
|
||||||
|
job.PreviewState = ""
|
||||||
|
job.PreviewStateAt = ""
|
||||||
|
job.PreviewStateMsg = ""
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if oldHls != "" && oldHls != newHls {
|
||||||
|
stopPreview(job)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
|
func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
prepare := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("prepare")))
|
||||||
|
if prepare == "1" || prepare == "true" || prepare == "yes" {
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.RLock()
|
||||||
|
job, ok := jobs[id]
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
if !ok || job == nil {
|
||||||
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := refreshPreviewLiveSourceForJob(r, job)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
type resp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Prepared bool `json:"prepared"`
|
||||||
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
|
LastError string `json:"lastError,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
_ = json.NewEncoder(w).Encode(resp{
|
||||||
|
OK: false,
|
||||||
|
Prepared: false,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
LastError: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(resp{
|
||||||
|
OK: true,
|
||||||
|
Prepared: true,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Route bleibt /api/preview/live
|
// ✅ Route bleibt /api/preview/live
|
||||||
// Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4).
|
|
||||||
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||||
if file == "" {
|
if file == "" {
|
||||||
recordPreviewLiveFMP4(w, r)
|
recordPreviewLiveFMP4(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst)
|
|
||||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
@ -579,10 +748,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
job, ok := jobs[id]
|
job, ok := jobs[id]
|
||||||
state := ""
|
state := ""
|
||||||
sourceURL := ""
|
|
||||||
if ok && job != nil {
|
if ok && job != nil {
|
||||||
state = strings.TrimSpace(job.PreviewState)
|
state = strings.TrimSpace(job.PreviewState)
|
||||||
sourceURL = strings.TrimSpace(job.SourceURL)
|
|
||||||
}
|
}
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
@ -591,39 +758,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
username := extractUsername(sourceURL)
|
_ = refreshPreviewLiveSourceForJob(r, job)
|
||||||
if strings.TrimSpace(username) != "" {
|
|
||||||
jobsMu.RLock()
|
|
||||||
cookie := strings.TrimSpace(job.PreviewCookie)
|
|
||||||
ua := strings.TrimSpace(job.PreviewUA)
|
|
||||||
jobsMu.RUnlock()
|
|
||||||
|
|
||||||
if ua == "" {
|
|
||||||
ua = "Mozilla/5.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
|
|
||||||
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
|
|
||||||
cancelRefresh()
|
|
||||||
|
|
||||||
if err == nil && strings.TrimSpace(newHls) != "" {
|
|
||||||
var oldHls string
|
|
||||||
|
|
||||||
jobsMu.Lock()
|
|
||||||
oldHls = strings.TrimSpace(job.PreviewM3U8)
|
|
||||||
job.PreviewM3U8 = strings.TrimSpace(newHls)
|
|
||||||
job.PreviewCookie = cookie
|
|
||||||
job.PreviewUA = ua
|
|
||||||
job.PreviewState = ""
|
|
||||||
job.PreviewStateAt = ""
|
|
||||||
job.PreviewStateMsg = ""
|
|
||||||
jobsMu.Unlock()
|
|
||||||
|
|
||||||
if oldHls != "" && oldHls != strings.TrimSpace(newHls) {
|
|
||||||
stopPreview(job)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nach möglichem Refresh alles nochmal sauber lesen
|
// Nach möglichem Refresh alles nochmal sauber lesen
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
|
|||||||
@ -7,15 +7,24 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafov/m3u8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- DVR-ähnlicher Recorder-Ablauf ---
|
// RecordStream für Chaturbate:
|
||||||
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
|
// bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist.
|
||||||
|
// Stattdessen wie in der alten, funktionierenden Version:
|
||||||
|
// - Seite laden
|
||||||
|
// - HLS URL parsen
|
||||||
|
// - beste Variant-Playlist auswählen
|
||||||
|
// - Segmente selbst laden und in .ts schreiben
|
||||||
func RecordStream(
|
func RecordStream(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hc *HTTPClient,
|
hc *HTTPClient,
|
||||||
@ -26,26 +35,30 @@ func RecordStream(
|
|||||||
job *RecordJob,
|
job *RecordJob,
|
||||||
) error {
|
) error {
|
||||||
username = strings.Trim(strings.TrimSpace(username), "/")
|
username = strings.Trim(strings.TrimSpace(username), "/")
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("leerer username")
|
||||||
|
}
|
||||||
|
|
||||||
base := strings.TrimRight(domain, "/")
|
base := strings.TrimRight(domain, "/")
|
||||||
pageURL := base + "/" + username + "/"
|
pageURL := base + "/" + username + "/"
|
||||||
|
|
||||||
loadFreshHLS := func() (string, error) {
|
loadFreshPlaylist := func() (*cbPlaylist, error) {
|
||||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("seite laden: %w", err)
|
return nil, fmt.Errorf("seite laden: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL, err := ParseStream(body)
|
hlsURL, err := ParseStream(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stream-parsing: %w", err)
|
return nil, fmt.Errorf("stream-parsing: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL = strings.TrimSpace(hlsURL)
|
hlsURL = strings.TrimSpace(hlsURL)
|
||||||
if hlsURL == "" {
|
if hlsURL == "" {
|
||||||
return "", errors.New("leere hls url")
|
return nil, errors.New("leere hls url")
|
||||||
}
|
}
|
||||||
|
|
||||||
finalURL, err := getWantedResolutionPlaylistWithHeaders(
|
finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders(
|
||||||
ctx,
|
ctx,
|
||||||
hlsURL,
|
hlsURL,
|
||||||
httpCookie,
|
httpCookie,
|
||||||
@ -53,13 +66,18 @@ func RecordStream(
|
|||||||
pageURL,
|
pageURL,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("variant-playlist: %w", err)
|
return nil, fmt.Errorf("variant-playlist: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(finalURL), nil
|
finalPlaylistURL = strings.TrimSpace(finalPlaylistURL)
|
||||||
|
if finalPlaylistURL == "" {
|
||||||
|
return nil, errors.New("leere final playlist url")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL, err := loadFreshHLS()
|
playlist, err := loadFreshPlaylist()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -71,7 +89,7 @@ func RecordStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PreviewM3U8 = hlsURL
|
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||||
job.PreviewCookie = httpCookie
|
job.PreviewCookie = httpCookie
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
@ -81,13 +99,62 @@ func RecordStream(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("datei erstellen: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = file.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var written int64
|
||||||
|
var lastPush time.Time
|
||||||
|
var lastBytes int64
|
||||||
|
published := false
|
||||||
|
|
||||||
|
handleSegment := func(b []byte, duration float64) error {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := file.Write(b); err != nil {
|
||||||
|
return fmt.Errorf("schreibe segment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = file.Sync()
|
||||||
|
|
||||||
|
if job != nil && !published {
|
||||||
|
published = true
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
written += int64(len(b))
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
now := time.Now()
|
||||||
|
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.SizeBytes = written
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
|
||||||
|
lastPush = now
|
||||||
|
lastBytes = written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = duration
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
const maxPlaylistRefreshes = 12
|
const maxPlaylistRefreshes = 12
|
||||||
refreshes := 0
|
refreshes := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL)
|
err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@ -98,14 +165,14 @@ func RecordStream(
|
|||||||
shouldRefresh :=
|
shouldRefresh :=
|
||||||
strings.Contains(msg, "403") ||
|
strings.Contains(msg, "403") ||
|
||||||
strings.Contains(msg, "401") ||
|
strings.Contains(msg, "401") ||
|
||||||
strings.Contains(msg, "invalid data found") ||
|
strings.Contains(msg, "playlist nicht mehr erreichbar") ||
|
||||||
strings.Contains(msg, "error opening input") ||
|
strings.Contains(msg, "fehlerhafte playlist") ||
|
||||||
|
strings.Contains(msg, "keine neuen hls-segmente") ||
|
||||||
strings.Contains(msg, "stream-parsing") ||
|
strings.Contains(msg, "stream-parsing") ||
|
||||||
strings.Contains(msg, "kein hls-quell-url") ||
|
|
||||||
strings.Contains(msg, "http ")
|
strings.Contains(msg, "http ")
|
||||||
|
|
||||||
if !shouldRefresh {
|
if !shouldRefresh {
|
||||||
return err
|
return fmt.Errorf("watch segments: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshes++
|
refreshes++
|
||||||
@ -125,7 +192,7 @@ func RecordStream(
|
|||||||
case <-time.After(1500 * time.Millisecond):
|
case <-time.After(1500 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
newHLS, rerr := loadFreshHLS()
|
newPlaylist, rerr := loadFreshPlaylist()
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
fmt.Println("⚠️ [cb] refresh failed:",
|
fmt.Println("⚠️ [cb] refresh failed:",
|
||||||
"user=", username,
|
"user=", username,
|
||||||
@ -141,16 +208,25 @@ func RecordStream(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hlsURL = newHLS
|
playlist = newPlaylist
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PreviewM3U8 = hlsURL
|
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||||
job.PreviewCookie = httpCookie
|
job.PreviewCookie = httpCookie
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.SizeBytes = written
|
||||||
|
jobsMu.Unlock()
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
||||||
@ -160,7 +236,6 @@ func ParseStream(html string) (string, error) {
|
|||||||
return "", errors.New("room dossier nicht gefunden")
|
return "", errors.New("room dossier nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DVR-Style Unicode-Decode
|
|
||||||
decoded, err := strconv.Unquote(
|
decoded, err := strconv.Unquote(
|
||||||
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
|
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
|
||||||
)
|
)
|
||||||
@ -180,7 +255,213 @@ func ParseStream(html string) (string, error) {
|
|||||||
return rd.HLSSource, nil
|
return rd.HLSSource, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
|
// eigener Typ nur für Chaturbate, damit es keinen Konflikt mit main.go gibt
|
||||||
|
type cbPlaylist struct {
|
||||||
|
PlaylistURL string
|
||||||
|
RootURL string
|
||||||
|
Resolution int
|
||||||
|
Framerate int
|
||||||
|
}
|
||||||
|
|
||||||
|
func cbFetchPlaylist(ctx context.Context, hc *HTTPClient, playlistURL string, httpCookie string) (*cbPlaylist, error) {
|
||||||
|
req, err := hc.NewRequest(ctx, http.MethodGet, playlistURL, httpCookie)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("playlist request erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := hc.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("playlist request senden: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("m3u8 parse: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if listType == m3u8.MEDIA {
|
||||||
|
root := playlistURL
|
||||||
|
if idx := strings.LastIndex(root, "/"); idx >= 0 {
|
||||||
|
root = root[:idx+1]
|
||||||
|
}
|
||||||
|
return &cbPlaylist{
|
||||||
|
PlaylistURL: playlistURL,
|
||||||
|
RootURL: root,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
master, ok := playlist.(*m3u8.MasterPlaylist)
|
||||||
|
if !ok || master == nil {
|
||||||
|
return nil, errors.New("unerwarteter playlist-typ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestURI string
|
||||||
|
var bestHeight int
|
||||||
|
var bestFramerate float64
|
||||||
|
|
||||||
|
for _, v := range master.Variants {
|
||||||
|
if v == nil || strings.TrimSpace(v.URI) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h := 0
|
||||||
|
if v.Resolution != "" {
|
||||||
|
parts := strings.Split(v.Resolution, "x")
|
||||||
|
if len(parts) == 2 {
|
||||||
|
if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
|
||||||
|
h = hh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fr := 30.0
|
||||||
|
if v.FrameRate > 0 {
|
||||||
|
fr = v.FrameRate
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) {
|
||||||
|
bestURI = strings.TrimSpace(v.URI)
|
||||||
|
bestHeight = h
|
||||||
|
bestFramerate = fr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestURI == "" {
|
||||||
|
return nil, errors.New("Master-Playlist ohne gültige Varianten")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, err := url.Parse(playlistURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
refURL, err := url.Parse(bestURI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
finalURL := baseURL.ResolveReference(refURL).String()
|
||||||
|
root := finalURL
|
||||||
|
if idx := strings.LastIndex(root, "/"); idx >= 0 {
|
||||||
|
root = root[:idx+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cbPlaylist{
|
||||||
|
PlaylistURL: finalURL,
|
||||||
|
RootURL: root,
|
||||||
|
Resolution: bestHeight,
|
||||||
|
Framerate: int(bestFramerate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *cbPlaylist) WatchSegments(
|
||||||
|
ctx context.Context,
|
||||||
|
hc *HTTPClient,
|
||||||
|
httpCookie string,
|
||||||
|
handler func([]byte, float64) error,
|
||||||
|
) error {
|
||||||
|
var lastSeq int64 = -1
|
||||||
|
emptyRounds := 0
|
||||||
|
const maxEmptyRounds = 60
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("playlist request erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := hc.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
emptyRounds++
|
||||||
|
if emptyRounds >= maxEmptyRounds {
|
||||||
|
return errors.New("playlist nicht mehr erreichbar – stream vermutlich offline")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if err != nil || listType != m3u8.MEDIA {
|
||||||
|
emptyRounds++
|
||||||
|
if emptyRounds >= maxEmptyRounds {
|
||||||
|
return errors.New("fehlerhafte playlist – möglicherweise offline")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
media := playlist.(*m3u8.MediaPlaylist)
|
||||||
|
newSegment := false
|
||||||
|
|
||||||
|
for _, segment := range media.Segments {
|
||||||
|
if segment == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if int64(segment.SeqId) <= lastSeq {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSeq = int64(segment.SeqId)
|
||||||
|
newSegment = true
|
||||||
|
|
||||||
|
segURL := segment.URI
|
||||||
|
if !strings.HasPrefix(segURL, "http://") && !strings.HasPrefix(segURL, "https://") {
|
||||||
|
segURL = p.RootURL + strings.TrimLeft(segment.URI, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
segReq, err := hc.NewRequest(ctx, http.MethodGet, segURL, httpCookie)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
segResp, err := hc.client.Do(segReq)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if segResp.StatusCode != http.StatusOK {
|
||||||
|
segResp.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(segResp.Body)
|
||||||
|
segResp.Body.Close()
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := handler(data, segment.Duration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newSegment {
|
||||||
|
emptyRounds = 0
|
||||||
|
} else {
|
||||||
|
emptyRounds++
|
||||||
|
if emptyRounds >= maxEmptyRounds {
|
||||||
|
return errors.New("keine neuen HLS-segmente empfangen – stream vermutlich beendet oder offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie-Hilfsfunktion
|
||||||
func addCookiesFromString(req *http.Request, cookieStr string) {
|
func addCookiesFromString(req *http.Request, cookieStr string) {
|
||||||
if cookieStr == "" {
|
if cookieStr == "" {
|
||||||
return
|
return
|
||||||
@ -203,14 +484,13 @@ func addCookiesFromString(req *http.Request, cookieStr string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helper ---
|
// helper
|
||||||
func extractUsername(input string) string {
|
func extractUsername(input string) string {
|
||||||
s := strings.TrimSpace(input)
|
s := strings.TrimSpace(input)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Falls volle URL
|
|
||||||
if u, err := url.Parse(s); err == nil && u != nil {
|
if u, err := url.Parse(s); err == nil && u != nil {
|
||||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||||
if strings.Contains(host, "chaturbate.com") {
|
if strings.Contains(host, "chaturbate.com") {
|
||||||
@ -223,7 +503,6 @@ func extractUsername(input string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: rohe Eingabe
|
|
||||||
s = strings.TrimPrefix(s, "https://")
|
s = strings.TrimPrefix(s, "https://")
|
||||||
s = strings.TrimPrefix(s, "http://")
|
s = strings.TrimPrefix(s, "http://")
|
||||||
s = strings.TrimPrefix(s, "www.")
|
s = strings.TrimPrefix(s, "www.")
|
||||||
@ -239,9 +518,8 @@ func extractUsername(input string) string {
|
|||||||
func hasChaturbateCookies(cookieStr string) bool {
|
func hasChaturbateCookies(cookieStr string) bool {
|
||||||
m := parseCookieString(cookieStr)
|
m := parseCookieString(cookieStr)
|
||||||
_, hasCF := m["cf_clearance"]
|
_, hasCF := m["cf_clearance"]
|
||||||
// akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower)
|
|
||||||
_, hasSessID := m["session_id"]
|
_, hasSessID := m["session_id"]
|
||||||
_, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt
|
_, hasSessIdAlt := m["sessionid"]
|
||||||
return hasCF && (hasSessID || hasSessIdAlt)
|
return hasCF && (hasSessID || hasSessIdAlt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -64,7 +65,10 @@ func getWantedResolutionPlaylistWithHeaders(
|
|||||||
return playlistURL, nil
|
return playlistURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
master := playlist.(*m3u8.MasterPlaylist)
|
master, ok := playlist.(*m3u8.MasterPlaylist)
|
||||||
|
if !ok || master == nil {
|
||||||
|
return "", errors.New("unerwarteter playlist-typ: keine master-playlist")
|
||||||
|
}
|
||||||
|
|
||||||
var bestURI string
|
var bestURI string
|
||||||
var bestHeight int
|
var bestHeight int
|
||||||
@ -79,7 +83,7 @@ func getWantedResolutionPlaylistWithHeaders(
|
|||||||
if v.Resolution != "" {
|
if v.Resolution != "" {
|
||||||
parts := strings.Split(v.Resolution, "x")
|
parts := strings.Split(v.Resolution, "x")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
if hh, err := strconv.Atoi(parts[1]); err == nil {
|
if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
|
||||||
h = hh
|
h = hh
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,10 +94,10 @@ func getWantedResolutionPlaylistWithHeaders(
|
|||||||
fr = v.FrameRate
|
fr = v.FrameRate
|
||||||
}
|
}
|
||||||
|
|
||||||
if h > bestHeight || (h == bestHeight && fr > bestFramerate) {
|
if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) {
|
||||||
|
bestURI = strings.TrimSpace(v.URI)
|
||||||
bestHeight = h
|
bestHeight = h
|
||||||
bestFramerate = fr
|
bestFramerate = fr
|
||||||
bestURI = strings.TrimSpace(v.URI)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,12 +109,15 @@ func getWantedResolutionPlaylistWithHeaders(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
refURL, err := url.Parse(bestURI)
|
refURL, err := url.Parse(bestURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseURL.ResolveReference(refURL).String(), nil
|
finalURL := baseURL.ResolveReference(refURL).String()
|
||||||
|
|
||||||
|
return finalURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
|
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
|
||||||
@ -153,34 +160,6 @@ func handleM3U8Mode(
|
|||||||
return fmt.Errorf("ungültige URL: %q", m3u8URL)
|
return fmt.Errorf("ungültige URL: %q", m3u8URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m3u8URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(userAgent) != "" {
|
|
||||||
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(httpCookie) != "" {
|
|
||||||
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(referer) != "" {
|
|
||||||
req.Header.Set("Referer", strings.TrimSpace(referer))
|
|
||||||
if ru, err := url.Parse(strings.TrimSpace(referer)); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
|
|
||||||
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
io.Copy(io.Discard, resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.TrimSpace(outFile) == "" {
|
if strings.TrimSpace(outFile) == "" {
|
||||||
return errors.New("output file path leer")
|
return errors.New("output file path leer")
|
||||||
}
|
}
|
||||||
@ -214,13 +193,34 @@ func handleM3U8Mode(
|
|||||||
args = append(args, "-headers", hdr)
|
args = append(args, "-headers", hdr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile)))
|
||||||
|
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"-i", m3u8URL,
|
"-i", m3u8URL,
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "0:a:0?",
|
||||||
|
"-dn",
|
||||||
|
"-ignore_unknown",
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-movflags", "+faststart",
|
|
||||||
outFile,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case ".ts":
|
||||||
|
args = append(args,
|
||||||
|
"-f", "mpegts",
|
||||||
|
outFile,
|
||||||
|
)
|
||||||
|
case ".mp4":
|
||||||
|
args = append(args,
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
outFile,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("nicht unterstützte output-endung: %q", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("🎬 ffmpeg hls args:", strings.Join(args, " "))
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
@ -246,6 +246,7 @@ func handleM3U8Mode(
|
|||||||
if err != nil || fi == nil || fi.IsDir() {
|
if err != nil || fi == nil || fi.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
sz := fi.Size()
|
sz := fi.Size()
|
||||||
if sz > 0 && sz != last {
|
if sz > 0 && sz != last {
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
@ -270,5 +271,14 @@ func handleM3U8Mode(
|
|||||||
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
|
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if job != nil {
|
||||||
|
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.SizeBytes = fi.Size()
|
||||||
|
jobsMu.Unlock()
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -483,7 +483,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
username = "unknown"
|
username = "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := fmt.Sprintf("%s_%s.mp4", username, startedAt.Format("01_02_2006__15-04-05"))
|
// ✅ Erst in TS aufnehmen
|
||||||
|
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
|
||||||
|
|
||||||
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
||||||
recordDir := strings.TrimSpace(recordDirAbs)
|
recordDir := strings.TrimSpace(recordDirAbs)
|
||||||
@ -554,7 +555,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||||
|
|
||||||
username := extractUsername(req.URL)
|
username := extractUsername(req.URL)
|
||||||
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
existingOut := strings.TrimSpace(job.Output)
|
existingOut := strings.TrimSpace(job.Output)
|
||||||
@ -584,7 +585,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||||
|
|
||||||
username := extractMFCUsername(req.URL)
|
username := extractMFCUsername(req.URL)
|
||||||
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
|
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
||||||
outPath := filepath.Join(recordDirAbs, filename)
|
outPath := filepath.Join(recordDirAbs, filename)
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
@ -642,9 +643,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// HARTE SCHRANKE 2: Output ungültig / leer vor Postwork
|
// HARTE SCHRANKE 2: Output prüfen, aber gestoppte Downloads
|
||||||
// WICHTIG: bei JobStopped kurz warten, damit Windows/File-Flush
|
// nicht aggressiv wegwerfen
|
||||||
// nach context canceled noch fertig werden kann
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
{
|
{
|
||||||
var (
|
var (
|
||||||
@ -653,12 +653,21 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if target == JobStopped {
|
if target == JobStopped {
|
||||||
fi, serr = waitForUsableOutput(out, 2*time.Second)
|
fi, serr = waitForUsableOutput(out, 3*time.Second)
|
||||||
} else {
|
} else {
|
||||||
fi, serr = os.Stat(out)
|
fi, serr = os.Stat(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
|
// Bei STOP nicht sofort wegwerfen – nur wenn wirklich nichts da ist
|
||||||
|
if target == JobStopped && shouldKeepStoppedJobForInspection(target, out) {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Phase = ""
|
||||||
|
job.Progress = 100
|
||||||
|
jobsMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
_ = removeWithRetry(out)
|
_ = removeWithRetry(out)
|
||||||
purgeDurationCacheForPath(out)
|
purgeDurationCacheForPath(out)
|
||||||
|
|
||||||
@ -808,6 +817,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1) Erst nach done verschieben
|
||||||
setPhase("moving", 10)
|
setPhase("moving", 10)
|
||||||
|
|
||||||
moved, err2 := moveToDoneDir(out)
|
moved, err2 := moveToDoneDir(out)
|
||||||
@ -819,12 +829,27 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
} else {
|
} else {
|
||||||
job.Error = "moveToDoneDir returned empty path"
|
job.Error = "moveToDoneDir returned empty path"
|
||||||
}
|
}
|
||||||
|
job.Output = out
|
||||||
job.Phase = ""
|
job.Phase = ""
|
||||||
job.Progress = 100
|
job.Progress = 100
|
||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if shouldKeepStoppedJobForInspection(postTarget, out) {
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "moving",
|
||||||
|
Label: "Verschieben nach done fehlgeschlagen",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
delete(jobs, job.ID)
|
delete(jobs, job.ID)
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
@ -847,7 +872,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
|
|
||||||
// 3) Datei nach move trotzdem kaputt
|
// 2) Nach move prüfen
|
||||||
{
|
{
|
||||||
fi, serr := os.Stat(out)
|
fi, serr := os.Stat(out)
|
||||||
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||||
@ -872,8 +897,108 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) ✅ TS -> MP4 Remux in den Nacharbeiten
|
||||||
|
if strings.EqualFold(filepath.Ext(out), ".ts") {
|
||||||
|
setPhase("remuxing", 35)
|
||||||
|
|
||||||
|
remuxed, rerr := maybeRemuxTS(out)
|
||||||
|
if rerr != nil || strings.TrimSpace(remuxed) == "" {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Status = JobFailed
|
||||||
|
if rerr != nil {
|
||||||
|
job.Error = "TS remux failed: " + rerr.Error()
|
||||||
|
} else {
|
||||||
|
job.Error = "TS remux returned empty path"
|
||||||
|
}
|
||||||
|
job.Output = out // originale .ts behalten
|
||||||
|
job.Phase = ""
|
||||||
|
job.Progress = 100
|
||||||
|
job.PostWorkKey = ""
|
||||||
|
job.PostWork = nil
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if shouldKeepStoppedJobForInspection(postTarget, out) {
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "remuxing",
|
||||||
|
Label: "TS Remux fehlgeschlagen",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
publishJobRemove(job)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "remuxing",
|
||||||
|
Label: "TS Remux fehlgeschlagen",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out = strings.TrimSpace(remuxed)
|
||||||
|
|
||||||
|
fi, serr := os.Stat(out)
|
||||||
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 || !strings.EqualFold(filepath.Ext(out), ".mp4") {
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Status = JobFailed
|
||||||
|
job.Error = "TS remux result invalid"
|
||||||
|
job.Phase = ""
|
||||||
|
job.Progress = 100
|
||||||
|
job.PostWorkKey = ""
|
||||||
|
job.PostWork = nil
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if shouldKeepStoppedJobForInspection(postTarget, out) {
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "remuxing",
|
||||||
|
Label: "Remux-Ergebnis ungültig",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
publishJobRemove(job)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: filepath.Base(out),
|
||||||
|
AssetID: assetIDFromVideoPath(out),
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "remuxing",
|
||||||
|
Label: "Remux-Ergebnis ungültig",
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
job.Output = out
|
||||||
|
job.SizeBytes = fi.Size()
|
||||||
|
jobsMu.Unlock()
|
||||||
|
notifyDoneChanged()
|
||||||
|
}
|
||||||
|
|
||||||
// 4) DURATION
|
// 4) DURATION
|
||||||
setPhase("probe", 35)
|
setPhase("probe", 60)
|
||||||
{
|
{
|
||||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
|
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
|
||||||
@ -885,7 +1010,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5) VIDEO PROPS
|
// 5) VIDEO PROPS
|
||||||
setPhase("probe", 75)
|
setPhase("probe", 85)
|
||||||
{
|
{
|
||||||
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
w, h, fps, perr := probeVideoProps(pctx, out)
|
w, h, fps, perr := probeVideoProps(pctx, out)
|
||||||
@ -950,11 +1075,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
jobsMu.Lock()
|
if postTarget == JobStopped {
|
||||||
delete(jobs, job.ID)
|
_ = publishJob(job.ID)
|
||||||
jobsMu.Unlock()
|
} else {
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
publishJobRemove(job)
|
publishJobRemove(job)
|
||||||
|
}
|
||||||
|
|
||||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
File: finalFile,
|
File: finalFile,
|
||||||
@ -1006,8 +1135,24 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.Progress = 100
|
job.Progress = 100
|
||||||
job.PostWorkKey = ""
|
job.PostWorkKey = ""
|
||||||
job.PostWork = nil
|
job.PostWork = nil
|
||||||
|
job.Error = "Nachbearbeitung konnte nicht eingeplant werden"
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
// Gestoppte Downloads mit existierender Datei nicht sofort aus der UI werfen
|
||||||
|
if shouldKeepStoppedJobForInspection(postTarget, out) {
|
||||||
|
_ = publishJob(job.ID)
|
||||||
|
|
||||||
|
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||||
|
File: postFile,
|
||||||
|
AssetID: postAssetID,
|
||||||
|
Queue: "postwork",
|
||||||
|
State: "error",
|
||||||
|
Phase: "postwork",
|
||||||
|
Label: "Nachbearbeitung konnte nicht eingeplant werden",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
delete(jobs, job.ID)
|
delete(jobs, job.ID)
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|||||||
@ -45,7 +45,6 @@ func main() {
|
|||||||
store := registerRoutes(mux, auth)
|
store := registerRoutes(mux, auth)
|
||||||
|
|
||||||
go startChaturbateOnlinePoller(store)
|
go startChaturbateOnlinePoller(store)
|
||||||
go startChaturbateBioContextPoller()
|
|
||||||
go startChaturbateAutoStartWorker(store)
|
go startChaturbateAutoStartWorker(store)
|
||||||
go startMyFreeCamsAutoStartWorker(store)
|
go startMyFreeCamsAutoStartWorker(store)
|
||||||
go startDiskSpaceGuard()
|
go startDiskSpaceGuard()
|
||||||
|
|||||||
@ -63,6 +63,7 @@ type finishedPostworkEvent struct {
|
|||||||
State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing"
|
State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing"
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
Position int `json:"position,omitempty"`
|
Position int `json:"position,omitempty"`
|
||||||
Waiting int `json:"waiting,omitempty"`
|
Waiting int `json:"waiting,omitempty"`
|
||||||
Running int `json:"running,omitempty"`
|
Running int `json:"running,omitempty"`
|
||||||
|
|||||||
@ -156,6 +156,7 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
|
|||||||
phase,
|
phase,
|
||||||
state,
|
state,
|
||||||
label,
|
label,
|
||||||
|
"",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -60,6 +61,7 @@ func publishFinishedPostworkPhase(
|
|||||||
phase string,
|
phase string,
|
||||||
state string,
|
state string,
|
||||||
label string,
|
label string,
|
||||||
|
errMsg string,
|
||||||
) {
|
) {
|
||||||
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
|
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
|
||||||
|
|
||||||
@ -71,6 +73,7 @@ func publishFinishedPostworkPhase(
|
|||||||
State: state,
|
State: state,
|
||||||
Phase: phase,
|
Phase: phase,
|
||||||
Label: label,
|
Label: label,
|
||||||
|
Error: strings.TrimSpace(errMsg),
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
if b, err := json.Marshal(ev); err == nil {
|
if b, err := json.Marshal(ev); err == nil {
|
||||||
@ -320,18 +323,24 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
|
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
|
||||||
setRegenerateAssetsTaskError(file, "Abgebrochen")
|
setRegenerateAssetsTaskError(file, "Abgebrochen")
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "", "")
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "", "")
|
||||||
|
|
||||||
removeRegenerateAssetsJobLater(file, 2*time.Second)
|
removeRegenerateAssetsJobLater(file, 2*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
failJob := func(queue, phase, msg string) {
|
failJob := func(queue, phase, msg string) {
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
|
||||||
|
if msg != "" {
|
||||||
|
fmt.Printf("❌ regenerate-assets error file=%s queue=%s phase=%s error=%s\n", file, queue, phase, msg)
|
||||||
|
}
|
||||||
|
|
||||||
if queue != "" && phase != "" {
|
if queue != "" && phase != "" {
|
||||||
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler")
|
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
finishRegenerateAssetsJob(file, "error", msg)
|
finishRegenerateAssetsJob(file, "error", msg)
|
||||||
@ -345,11 +354,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialstatus
|
// Initialstatus
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
|
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
||||||
|
|
||||||
// Alte Assets löschen
|
// Alte Assets löschen
|
||||||
if id != "" {
|
if id != "" {
|
||||||
@ -376,12 +385,12 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
|
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta", "")
|
||||||
|
|
||||||
// 2) Thumb
|
// 2) Thumb
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild", "")
|
||||||
|
|
||||||
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil {
|
if _, err := ensureThumbForVideoCtx(ctx, videoPath, ""); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||||
cancelJob()
|
cancelJob()
|
||||||
return
|
return
|
||||||
@ -395,10 +404,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild", "")
|
||||||
|
|
||||||
// 3) Teaser
|
// 3) Teaser
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser", "")
|
||||||
|
|
||||||
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
|
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||||
@ -414,10 +423,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser", "")
|
||||||
|
|
||||||
// 4) Sprites
|
// 4) Sprites
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites", "")
|
||||||
|
|
||||||
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
|
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||||
@ -433,10 +442,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
||||||
|
|
||||||
// 5) KI-Analyse
|
// 5) KI-Analyse
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "")
|
||||||
|
|
||||||
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
|
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
|
||||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||||
@ -452,7 +461,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "")
|
||||||
|
|
||||||
finishRegenerateAssetsJob(file, "done", "")
|
finishRegenerateAssetsJob(file, "done", "")
|
||||||
setRegenerateAssetsTaskDone(file, "Fertig")
|
setRegenerateAssetsTaskDone(file, "Fertig")
|
||||||
@ -590,11 +599,11 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
enqueueRegenerateAssetsJob(job)
|
enqueueRegenerateAssetsJob(job)
|
||||||
|
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta")
|
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
|
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
|
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
|
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
|
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|||||||
@ -93,6 +93,7 @@ type JobEvent = {
|
|||||||
modelImageUrl?: string
|
modelImageUrl?: string
|
||||||
modelChatRoomUrl?: string
|
modelChatRoomUrl?: string
|
||||||
ts?: number
|
ts?: number
|
||||||
|
error?: string
|
||||||
|
|
||||||
postWorkKey?: string
|
postWorkKey?: string
|
||||||
postWork?: {
|
postWork?: {
|
||||||
@ -124,18 +125,6 @@ type TaskStateEvent = {
|
|||||||
ts?: number
|
ts?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChaturbateBioContextResponse = {
|
|
||||||
enabled?: boolean
|
|
||||||
fetchedAt?: string
|
|
||||||
lastError?: string
|
|
||||||
model?: string
|
|
||||||
roomStatus?: string
|
|
||||||
isOnline?: boolean
|
|
||||||
chatRoomUrl?: string
|
|
||||||
imageUrl?: string
|
|
||||||
bio?: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||||
recordDir: 'records',
|
recordDir: 'records',
|
||||||
doneDir: 'records/done',
|
doneDir: 'records/done',
|
||||||
@ -213,39 +202,6 @@ type AutostartState = {
|
|||||||
pausedByDisk?: boolean
|
pausedByDisk?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChaturbateBioContextStatus(
|
|
||||||
modelKey: string,
|
|
||||||
cookiesObj: Record<string, string>
|
|
||||||
): Promise<{
|
|
||||||
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
|
|
||||||
imageUrl?: string
|
|
||||||
chatRoomUrl?: string
|
|
||||||
}> {
|
|
||||||
const cookieHeader = Object.entries(cookiesObj)
|
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
|
||||||
.join('; ')
|
|
||||||
|
|
||||||
const res = await fetch(`/api/chaturbate/biocontext?model=${encodeURIComponent(modelKey)}&refresh=1`, {
|
|
||||||
method: 'GET',
|
|
||||||
cache: 'no-store',
|
|
||||||
headers: cookieHeader
|
|
||||||
? { 'X-Chaturbate-Cookie': cookieHeader }
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return { show: 'unknown' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as ChaturbateBioContextResponse | null
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: normalizePendingShow(data?.roomStatus),
|
|
||||||
imageUrl: String(data?.imageUrl ?? '').trim() || undefined,
|
|
||||||
chatRoomUrl: String(data?.chatRoomUrl ?? '').trim() || undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||||||
const s = String(v ?? '').trim().toLowerCase()
|
const s = String(v ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
@ -1628,18 +1584,7 @@ export default function App() {
|
|||||||
|
|
||||||
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
|
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
|
||||||
if (ok && next.pendingKeyLower) {
|
if (ok && next.pendingKeyLower) {
|
||||||
const kLower = next.pendingKeyLower
|
removePendingAutoStart(next.pendingKeyLower)
|
||||||
|
|
||||||
setPendingAutoStartByKey((prev) => {
|
|
||||||
const copy = { ...(prev || {}) }
|
|
||||||
delete copy[kLower]
|
|
||||||
pendingAutoStartByKeyRef.current = copy
|
|
||||||
return copy
|
|
||||||
})
|
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) =>
|
|
||||||
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== kLower)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// dedupe wieder freigeben
|
// dedupe wieder freigeben
|
||||||
@ -1772,6 +1717,53 @@ export default function App() {
|
|||||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||||
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
||||||
|
|
||||||
|
const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => {
|
||||||
|
if (!keyLower) return
|
||||||
|
|
||||||
|
setPendingAutoStartByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}), [keyLower]: url }
|
||||||
|
pendingAutoStartByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setPendingWatchedRooms((prev) => {
|
||||||
|
const nextItem: PendingWatchedRoom = {
|
||||||
|
id: keyLower,
|
||||||
|
modelKey: keyLower,
|
||||||
|
url,
|
||||||
|
currentShow: normalizePendingShow(show),
|
||||||
|
imageUrl: imageUrl || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = prev.findIndex(
|
||||||
|
(x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower
|
||||||
|
)
|
||||||
|
|
||||||
|
if (idx >= 0) {
|
||||||
|
const copy = [...prev]
|
||||||
|
copy[idx] = { ...copy[idx], ...nextItem }
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
return [nextItem, ...prev]
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removePendingAutoStart = useCallback((keyLower: string) => {
|
||||||
|
if (!keyLower) return
|
||||||
|
|
||||||
|
setPendingAutoStartByKey((prev) => {
|
||||||
|
const next = { ...(prev || {}) }
|
||||||
|
delete next[keyLower]
|
||||||
|
pendingAutoStartByKeyRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setPendingWatchedRooms((prev) =>
|
||||||
|
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower)
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const selectedTabRef = useRef(selectedTab)
|
const selectedTabRef = useRef(selectedTab)
|
||||||
|
|
||||||
const applyPendingRoomSnapshot = useCallback(
|
const applyPendingRoomSnapshot = useCallback(
|
||||||
@ -1908,12 +1900,6 @@ export default function App() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) return
|
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
|
||||||
|
|
||||||
const rooms = Array.isArray(data?.rooms) ? data!.rooms : []
|
|
||||||
|
|
||||||
const byKey: Record<
|
const byKey: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@ -1923,20 +1909,30 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
> = {}
|
> = {}
|
||||||
|
|
||||||
for (const room of rooms) {
|
if (res.ok) {
|
||||||
const key = String(room?.username ?? '').trim().toLowerCase()
|
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||||
if (!key) continue
|
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
|
||||||
|
|
||||||
byKey[key] = {
|
for (const room of rooms) {
|
||||||
show: normalizePendingShow(room?.current_show),
|
const key = String(room?.username ?? '').trim().toLowerCase()
|
||||||
imageUrl: String(room?.image_url ?? '').trim() || undefined,
|
if (!key) continue
|
||||||
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
|
|
||||||
|
byKey[key] = {
|
||||||
|
show: normalizePendingShow(room?.current_show),
|
||||||
|
imageUrl: String(room?.image_url ?? '').trim() || undefined,
|
||||||
|
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nicht gelieferte Keys => offline
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const snap = byKey[key] ?? { show: 'offline' as const }
|
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, {
|
maybeNotifyWatchedModelStatusChange(key, snap.show, {
|
||||||
imageUrl: snap.imageUrl,
|
imageUrl: snap.imageUrl,
|
||||||
@ -1944,16 +1940,24 @@ export default function App() {
|
|||||||
|
|
||||||
applyPendingRoomSnapshot(key, snap)
|
applyPendingRoomSnapshot(key, snap)
|
||||||
|
|
||||||
if (snap.show === 'public') {
|
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
||||||
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
if (!url) continue
|
||||||
if (!url) continue
|
|
||||||
|
|
||||||
|
if (snap.show === 'public') {
|
||||||
enqueueStart({
|
enqueueStart({
|
||||||
url,
|
url,
|
||||||
silent: true,
|
silent: true,
|
||||||
pendingKeyLower: key,
|
pendingKeyLower: key,
|
||||||
})
|
})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (snap.show === 'offline') {
|
||||||
|
removePendingAutoStart(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// private / hidden / away / unknown bleiben in der Warteschlange
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -1976,7 +1980,13 @@ export default function App() {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
if (timer != null) window.clearTimeout(timer)
|
if (timer != null) window.clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [recSettings.useChaturbateApi, applyPendingRoomSnapshot, enqueueStart])
|
}, [
|
||||||
|
recSettings.useChaturbateApi,
|
||||||
|
applyPendingRoomSnapshot,
|
||||||
|
enqueueStart,
|
||||||
|
maybeNotifyWatchedModelStatusChange,
|
||||||
|
removePendingAutoStart,
|
||||||
|
])
|
||||||
|
|
||||||
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
||||||
|
|
||||||
@ -2104,39 +2114,16 @@ export default function App() {
|
|||||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
// silent bleibt günstig wie bisher
|
// silent: direkt in Queue legen, Status kommt später über den Poller
|
||||||
if (silent) {
|
if (silent) {
|
||||||
setPendingAutoStartByKey((prev) => {
|
queuePendingAutoStart(mkLower, norm, 'unknown')
|
||||||
const next = { ...(prev || {}), [mkLower]: norm }
|
|
||||||
pendingAutoStartByKeyRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) => {
|
|
||||||
const nextItem: PendingWatchedRoom = {
|
|
||||||
id: mkLower,
|
|
||||||
modelKey: mkLower,
|
|
||||||
url: norm,
|
|
||||||
currentShow: 'unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = prev.findIndex(
|
|
||||||
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
|
|
||||||
)
|
|
||||||
|
|
||||||
if (idx >= 0) {
|
|
||||||
const copy = [...prev]
|
|
||||||
copy[idx] = { ...copy[idx], ...nextItem }
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
return [nextItem, ...prev]
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// manuell: erst /online
|
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
|
||||||
|
let resolvedImageUrl: string | undefined
|
||||||
|
let resolvedChatRoomUrl: string | undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -2147,67 +2134,51 @@ export default function App() {
|
|||||||
body: JSON.stringify({ q: [mkLower] }),
|
body: JSON.stringify({ q: [mkLower] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
let handled = false
|
|
||||||
|
|
||||||
if (onlineRes.ok) {
|
if (onlineRes.ok) {
|
||||||
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||||
const room = Array.isArray(onlineData?.rooms)
|
const room = Array.isArray(onlineData?.rooms)
|
||||||
? onlineData!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
|
? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
applyPendingRoomSnapshot(mkLower, {
|
resolvedShow = normalizePendingShow(room.current_show)
|
||||||
show: normalizePendingShow(room.current_show),
|
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
|
||||||
imageUrl: String(room.image_url ?? '').trim() || undefined,
|
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
|
||||||
chatRoomUrl: String(room.chat_room_url ?? '').trim() || undefined,
|
|
||||||
})
|
|
||||||
handled = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!handled) {
|
|
||||||
const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies)
|
|
||||||
applyPendingRoomSnapshot(mkLower, {
|
|
||||||
show: bio.show,
|
|
||||||
imageUrl: bio.imageUrl,
|
|
||||||
chatRoomUrl: bio.chatRoomUrl,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// best effort
|
// best effort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPendingRoomSnapshot(mkLower, {
|
||||||
|
show: resolvedShow,
|
||||||
|
imageUrl: resolvedImageUrl,
|
||||||
|
chatRoomUrl: resolvedChatRoomUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedShow === 'private' ||
|
||||||
|
resolvedShow === 'hidden' ||
|
||||||
|
resolvedShow === 'away' ||
|
||||||
|
resolvedShow === 'unknown'
|
||||||
|
) {
|
||||||
|
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedShow === 'offline') {
|
||||||
|
removePendingAutoStart(mkLower)
|
||||||
|
setError('Model ist aktuell offline und wurde nicht zur Warteschlange hinzugefügt.')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// nur bei public unten normal starten
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (silent) {
|
if (silent) {
|
||||||
const mkLower = providerKeyLowerFromUrl(norm)
|
const mkLower = providerKeyLowerFromUrl(norm)
|
||||||
if (mkLower) {
|
if (mkLower) {
|
||||||
setPendingAutoStartByKey((prev) => {
|
queuePendingAutoStart(mkLower, norm, 'unknown')
|
||||||
const next = { ...(prev || {}), [mkLower]: norm }
|
|
||||||
pendingAutoStartByKeyRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) => {
|
|
||||||
const nextItem: PendingWatchedRoom = {
|
|
||||||
id: mkLower,
|
|
||||||
modelKey: mkLower,
|
|
||||||
url: norm,
|
|
||||||
currentShow: 'unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
const idx = prev.findIndex(
|
|
||||||
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
|
|
||||||
)
|
|
||||||
|
|
||||||
if (idx >= 0) {
|
|
||||||
const copy = [...prev]
|
|
||||||
copy[idx] = { ...copy[idx], ...nextItem }
|
|
||||||
return copy
|
|
||||||
}
|
|
||||||
|
|
||||||
return [nextItem, ...prev]
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2510,6 +2481,7 @@ export default function App() {
|
|||||||
state,
|
state,
|
||||||
phase: String(msg?.phase ?? '').trim() || undefined,
|
phase: String(msg?.phase ?? '').trim() || undefined,
|
||||||
label: String(msg?.label ?? '').trim() || undefined,
|
label: String(msg?.label ?? '').trim() || undefined,
|
||||||
|
error: String(msg?.error ?? '').trim() || undefined,
|
||||||
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
|
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
|
||||||
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
|
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
|
||||||
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
|
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
|
||||||
@ -2785,34 +2757,19 @@ export default function App() {
|
|||||||
|
|
||||||
const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
|
const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
|
||||||
const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
|
const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
|
||||||
const url = String(pending?.url ?? '').trim()
|
|
||||||
|
|
||||||
// 1) Aus pendingAutoStartByKey entfernen
|
|
||||||
if (keyLower) {
|
if (keyLower) {
|
||||||
setPendingAutoStartByKey((prev) => {
|
removePendingAutoStart(keyLower)
|
||||||
const next = { ...(prev || {}) }
|
return
|
||||||
delete next[keyLower]
|
|
||||||
pendingAutoStartByKeyRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Aus sichtbarer Warteliste entfernen
|
const url = String(pending?.url ?? '').trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
setPendingWatchedRooms((prev) =>
|
setPendingWatchedRooms((prev) =>
|
||||||
prev.filter((x) => {
|
prev.filter((x) => String(x?.url ?? '').trim() !== url)
|
||||||
const xKey = String(x?.modelKey ?? '').trim().toLowerCase()
|
|
||||||
const xUrl = String(x?.url ?? '').trim()
|
|
||||||
|
|
||||||
// bevorzugt über modelKey entfernen
|
|
||||||
if (keyLower && xKey === keyLower) return false
|
|
||||||
|
|
||||||
// Fallback über URL
|
|
||||||
if (!keyLower && url && xUrl === url) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
}, [])
|
}, [removePendingAutoStart])
|
||||||
|
|
||||||
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArchiveBoxIcon,
|
ArchiveBoxIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
|
ClipboardDocumentIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { type SwipeCardHandle } from './SwipeCard'
|
import { type SwipeCardHandle } from './SwipeCard'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
@ -245,6 +246,85 @@ function sleep(ms: number) {
|
|||||||
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
|
return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||||
|
const value = String(text ?? '').trim()
|
||||||
|
if (!value) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = value
|
||||||
|
ta.setAttribute('readonly', '')
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
ta.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.focus()
|
||||||
|
ta.select()
|
||||||
|
const ok = document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
return ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyErrorButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = React.useState(false)
|
||||||
|
const timeoutRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current != null) {
|
||||||
|
window.clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
const ok = await copyTextToClipboard(text || 'Unbekannter Fehler')
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setCopied(true)
|
||||||
|
|
||||||
|
if (timeoutRef.current != null) {
|
||||||
|
window.clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopied(false)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
|
||||||
|
title={copied ? 'Kopiert' : 'Fehler kopieren'}
|
||||||
|
aria-label={copied ? 'Fehler kopiert' : 'Fehler kopieren'}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ClipboardDocumentIcon className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function errorTextOf(err: unknown): string {
|
function errorTextOf(err: unknown): string {
|
||||||
if (err instanceof Error) return err.message || String(err)
|
if (err instanceof Error) return err.message || String(err)
|
||||||
return String(err ?? '')
|
return String(err ?? '')
|
||||||
@ -905,6 +985,7 @@ function summarizePostworkSteps(
|
|||||||
phase,
|
phase,
|
||||||
label: labelForStep(queue, phase),
|
label: labelForStep(queue, phase),
|
||||||
state: badge.state,
|
state: badge.state,
|
||||||
|
error: String(badge.error ?? '').trim() || undefined,
|
||||||
ts: badge.ts,
|
ts: badge.ts,
|
||||||
position: badge.position,
|
position: badge.position,
|
||||||
waiting: badge.waiting,
|
waiting: badge.waiting,
|
||||||
@ -1035,7 +1116,9 @@ function mergePostworkSummaries(
|
|||||||
step.state === 'queued' ||
|
step.state === 'queued' ||
|
||||||
step.state === 'error'
|
step.state === 'error'
|
||||||
) {
|
) {
|
||||||
stepMap.set(step.key, step)
|
if (!prev || (step.ts || 0) >= (prev.ts || 0)) {
|
||||||
|
stepMap.set(step.key, step)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1227,10 +1310,18 @@ export function renderFinishedPostworkBadge(
|
|||||||
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
|
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
|
||||||
</>
|
</>
|
||||||
) : step.state === 'error' ? (
|
) : step.state === 'error' ? (
|
||||||
<>
|
<div className="flex items-center gap-1.5">
|
||||||
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
|
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
|
||||||
<span className="text-red-700 dark:text-red-200">Fehler</span>
|
|
||||||
</>
|
<span
|
||||||
|
className="text-red-700 dark:text-red-200"
|
||||||
|
title={step.error || 'Unbekannter Fehler'}
|
||||||
|
>
|
||||||
|
Fehler
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{step.error ? <CopyErrorButton text={step.error} /> : null}
|
||||||
|
</div>
|
||||||
) : step.state === 'missing' ? (
|
) : step.state === 'missing' ? (
|
||||||
<>
|
<>
|
||||||
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
|
||||||
@ -2586,6 +2677,11 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error('regenerate-assets failed', {
|
||||||
|
file: cleanFile,
|
||||||
|
error: e?.message ? String(e.message) : String(e),
|
||||||
|
})
|
||||||
|
|
||||||
setPostworkByFile((prev) => ({
|
setPostworkByFile((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[cleanFile]: [
|
[cleanFile]: [
|
||||||
@ -3188,6 +3284,7 @@ export default function FinishedDownloads({
|
|||||||
String(detail?.label ?? '').trim() === file
|
String(detail?.label ?? '').trim() === file
|
||||||
? undefined
|
? undefined
|
||||||
: detail?.label,
|
: detail?.label,
|
||||||
|
error: String(detail?.error ?? '').trim() || undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const cur = Array.isArray(prev[file]) ? prev[file] : []
|
const cur = Array.isArray(prev[file]) ? prev[file] : []
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\components\ui\HoverPopover.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -16,20 +18,25 @@ type Pos = { left: number; top: number }
|
|||||||
type HoverPopoverAPI = { close: () => void }
|
type HoverPopoverAPI = { close: () => void }
|
||||||
|
|
||||||
type HoverPopoverProps = PropsWithChildren<{
|
type HoverPopoverProps = PropsWithChildren<{
|
||||||
// Entweder direkt ein ReactNode
|
|
||||||
// oder eine Renderfunktion, die den Open-Status bekommt
|
|
||||||
// (2. Param erlaubt z.B. Close-Button im Popover)
|
|
||||||
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
|
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
}>
|
}>
|
||||||
|
|
||||||
export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
export default function HoverPopover({ children, content, onOpenChange }: HoverPopoverProps) {
|
||||||
const triggerRef = useRef<HTMLDivElement>(null)
|
const triggerRef = useRef<HTMLDivElement>(null)
|
||||||
const popoverRef = useRef<HTMLDivElement>(null)
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [pos, setPos] = useState<Pos | null>(null)
|
const [pos, setPos] = useState<Pos | null>(null)
|
||||||
|
|
||||||
// Timeout-Ref für verzögertes Schließen
|
const setOpenState = (next: boolean) => {
|
||||||
|
setOpen((prev) => (prev === next ? prev : next))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpenChange?.(open)
|
||||||
|
}, [open, onOpenChange])
|
||||||
|
|
||||||
const closeTimeoutRef = useRef<number | null>(null)
|
const closeTimeoutRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const clearCloseTimeout = () => {
|
const clearCloseTimeout = () => {
|
||||||
@ -42,14 +49,14 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
|||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
clearCloseTimeout()
|
clearCloseTimeout()
|
||||||
closeTimeoutRef.current = window.setTimeout(() => {
|
closeTimeoutRef.current = window.setTimeout(() => {
|
||||||
setOpen(false)
|
setOpenState(false)
|
||||||
closeTimeoutRef.current = null
|
closeTimeoutRef.current = null
|
||||||
}, 150) // 150ms „Gnadenzeit“ zum Rüberfahren
|
}, 150)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnter = () => {
|
const handleEnter = () => {
|
||||||
clearCloseTimeout()
|
clearCloseTimeout()
|
||||||
setOpen(true)
|
setOpenState(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLeave = () => {
|
const handleLeave = () => {
|
||||||
@ -58,7 +65,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
|||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
clearCloseTimeout()
|
clearCloseTimeout()
|
||||||
setOpen(false)
|
setOpenState(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const computePos = () => {
|
const computePos = () => {
|
||||||
@ -67,25 +74,19 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
|||||||
if (!trigger || !pop) return
|
if (!trigger || !pop) return
|
||||||
|
|
||||||
const gap = 8
|
const gap = 8
|
||||||
const pad = 8 // Abstand zum Viewport-Rand
|
const pad = 8
|
||||||
const r = trigger.getBoundingClientRect()
|
const r = trigger.getBoundingClientRect()
|
||||||
const pr = pop.getBoundingClientRect()
|
const pr = pop.getBoundingClientRect()
|
||||||
|
|
||||||
// Prefer: unten
|
|
||||||
let top = r.bottom + gap
|
let top = r.bottom + gap
|
||||||
const bottomOverflow = top + pr.height > window.innerHeight - pad
|
const bottomOverflow = top + pr.height > window.innerHeight - pad
|
||||||
|
|
||||||
if (bottomOverflow) {
|
if (bottomOverflow) {
|
||||||
// Versuch: oben
|
|
||||||
const above = r.top - pr.height - gap
|
const above = r.top - pr.height - gap
|
||||||
if (above >= pad) top = above
|
if (above >= pad) top = above
|
||||||
else {
|
else top = Math.max(pad, window.innerHeight - pr.height - pad)
|
||||||
// Notfalls clamp ins Viewport
|
|
||||||
top = Math.max(pad, window.innerHeight - pr.height - pad)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Left clamp
|
|
||||||
let left = r.left
|
let left = r.left
|
||||||
if (left + pr.width > window.innerWidth - pad) {
|
if (left + pr.width > window.innerWidth - pad) {
|
||||||
left = window.innerWidth - pr.width - pad
|
left = window.innerWidth - pr.width - pad
|
||||||
@ -95,15 +96,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
|||||||
setPos({ left, top })
|
setPos({ left, top })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beim Öffnen: erst rendern, dann messen/positionieren
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const id = requestAnimationFrame(() => computePos())
|
const id = requestAnimationFrame(() => computePos())
|
||||||
return () => cancelAnimationFrame(id)
|
return () => cancelAnimationFrame(id)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Reposition bei Scroll/Resize solange offen
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onMove = () => requestAnimationFrame(() => computePos())
|
const onMove = () => requestAnimationFrame(() => computePos())
|
||||||
@ -113,15 +111,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
|||||||
window.removeEventListener('resize', onMove)
|
window.removeEventListener('resize', onMove)
|
||||||
window.removeEventListener('scroll', onMove, true)
|
window.removeEventListener('scroll', onMove, true)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
// Cleanup für Timeout
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => clearCloseTimeout()
|
return () => clearCloseTimeout()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Hilfsfunktion: content normalisieren
|
|
||||||
const renderContent = () =>
|
const renderContent = () =>
|
||||||
typeof content === 'function'
|
typeof content === 'function'
|
||||||
? (content as any)(open, { close })
|
? (content as any)(open, { close })
|
||||||
|
|||||||
@ -12,6 +12,8 @@ export default function LiveVideo({
|
|||||||
className,
|
className,
|
||||||
roomStatus,
|
roomStatus,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
|
onLoadingStart,
|
||||||
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
src: string
|
src: string
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
@ -19,6 +21,8 @@ export default function LiveVideo({
|
|||||||
className?: string
|
className?: string
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
onVolumeChange?: (volume: number, muted: boolean) => void
|
onVolumeChange?: (volume: number, muted: boolean) => void
|
||||||
|
onLoadingStart?: () => void
|
||||||
|
onReady?: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const [broken, setBroken] = useState(false)
|
const [broken, setBroken] = useState(false)
|
||||||
@ -56,8 +60,8 @@ export default function LiveVideo({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
|
||||||
let watchdogTimer: number | null = null
|
let watchdogTimer: number | null = null
|
||||||
|
let readySent = false
|
||||||
|
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
@ -77,28 +81,58 @@ export default function LiveVideo({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) src setzen (fMP4/MP4 stream, single HTTP response)
|
|
||||||
hardReset()
|
hardReset()
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
setBroken(false)
|
||||||
|
setBrokenReason(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoadingStart?.()
|
||||||
|
|
||||||
|
const markReady = () => {
|
||||||
|
if (readySent) return
|
||||||
|
readySent = true
|
||||||
|
onReady?.()
|
||||||
|
}
|
||||||
|
|
||||||
video.src = src
|
video.src = src
|
||||||
video.load()
|
video.load()
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => {})
|
||||||
|
|
||||||
// 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen
|
|
||||||
let lastT = Date.now()
|
let lastT = Date.now()
|
||||||
const onTime = () => {
|
const onTime = () => {
|
||||||
lastT = Date.now()
|
lastT = Date.now()
|
||||||
|
markReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLoadedData = () => {
|
||||||
|
markReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCanPlay = () => {
|
||||||
|
markReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPlaying = () => {
|
||||||
|
markReady()
|
||||||
|
}
|
||||||
|
|
||||||
video.addEventListener('timeupdate', onTime)
|
video.addEventListener('timeupdate', onTime)
|
||||||
|
video.addEventListener('loadeddata', onLoadedData)
|
||||||
|
video.addEventListener('canplay', onCanPlay)
|
||||||
|
video.addEventListener('playing', onPlaying)
|
||||||
|
|
||||||
let stalledRetries = 0
|
let stalledRetries = 0
|
||||||
|
|
||||||
watchdogTimer = window.setInterval(() => {
|
watchdogTimer = window.setInterval(() => {
|
||||||
if (cancelled) return
|
|
||||||
|
|
||||||
// wenn nicht paused, aber 12s kein Fortschritt -> reconnect versuchen
|
|
||||||
if (!video.paused && Date.now() - lastT > 12_000) {
|
if (!video.paused && Date.now() - lastT > 12_000) {
|
||||||
if (stalledRetries < 1) {
|
if (stalledRetries < 1) {
|
||||||
stalledRetries += 1
|
stalledRetries += 1
|
||||||
|
readySent = false
|
||||||
|
onLoadingStart?.()
|
||||||
|
|
||||||
hardReset()
|
hardReset()
|
||||||
video.src = src
|
video.src = src
|
||||||
video.load()
|
video.load()
|
||||||
@ -118,30 +152,30 @@ export default function LiveVideo({
|
|||||||
}
|
}
|
||||||
}, 4_000)
|
}, 4_000)
|
||||||
|
|
||||||
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
|
|
||||||
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
|
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
const reason = normalizeBrokenReason(roomStatus)
|
const reason = normalizeBrokenReason(roomStatus)
|
||||||
|
|
||||||
if (reason === 'unknown') {
|
if (reason === 'unknown') {
|
||||||
// bei unbekanntem Raumstatus nicht sofort hart fehlschlagen
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setBroken(true)
|
setBroken(true)
|
||||||
setBrokenReason(reason)
|
setBrokenReason(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
video.addEventListener('error', onError)
|
video.addEventListener('error', onError)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
|
||||||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||||||
watchdogTimer = null
|
watchdogTimer = null
|
||||||
video.removeEventListener('timeupdate', onTime)
|
video.removeEventListener('timeupdate', onTime)
|
||||||
|
video.removeEventListener('loadeddata', onLoadedData)
|
||||||
|
video.removeEventListener('canplay', onCanPlay)
|
||||||
|
video.removeEventListener('playing', onPlaying)
|
||||||
video.removeEventListener('error', onError)
|
video.removeEventListener('error', onError)
|
||||||
hardReset()
|
hardReset()
|
||||||
}
|
}
|
||||||
}, [src, roomStatus])
|
}, [src, roomStatus, muted, volume, onLoadingStart, onReady])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import {
|
|||||||
SpeakerXMarkIcon,
|
SpeakerXMarkIcon,
|
||||||
SpeakerWaveIcon,
|
SpeakerWaveIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobId: string
|
jobId: string
|
||||||
|
modelKey?: string
|
||||||
live?: boolean
|
live?: boolean
|
||||||
thumbTick?: number
|
thumbTick?: number
|
||||||
autoTickMs?: number
|
autoTickMs?: number
|
||||||
@ -22,6 +24,22 @@ type Props = {
|
|||||||
fallbackSrc?: string
|
fallbackSrc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildChaturbateCookieHeader(): string {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem('record_cookies')
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
const obj = JSON.parse(raw) as Record<string, string>
|
||||||
|
return Object.entries(obj ?? {})
|
||||||
|
.map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const)
|
||||||
|
.filter(([k, v]) => k && v)
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function ModelPreview({
|
export default function ModelPreview({
|
||||||
jobId,
|
jobId,
|
||||||
live = false,
|
live = false,
|
||||||
@ -38,6 +56,11 @@ export default function ModelPreview({
|
|||||||
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
||||||
const [localTick, setLocalTick] = useState(0)
|
const [localTick, setLocalTick] = useState(0)
|
||||||
const [imgError, setImgError] = useState(false)
|
const [imgError, setImgError] = useState(false)
|
||||||
|
const [liveSrc, setLiveSrc] = useState('')
|
||||||
|
const [hoverOpen, setHoverOpen] = useState(false)
|
||||||
|
|
||||||
|
const [livePreparing, setLivePreparing] = useState(false)
|
||||||
|
const [liveReady, setLiveReady] = useState(false)
|
||||||
|
|
||||||
const [popupMuted, setPopupMuted] = useState(true)
|
const [popupMuted, setPopupMuted] = useState(true)
|
||||||
const [popupVolume, setPopupVolume] = useState(1)
|
const [popupVolume, setPopupVolume] = useState(1)
|
||||||
@ -63,7 +86,7 @@ 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 hq = useMemo(
|
const liveStreamSrc = useMemo(
|
||||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
||||||
[jobId]
|
[jobId]
|
||||||
)
|
)
|
||||||
@ -74,6 +97,63 @@ export default function ModelPreview({
|
|||||||
return () => document.removeEventListener('visibilitychange', onVis)
|
return () => document.removeEventListener('visibilitychange', onVis)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController()
|
||||||
|
|
||||||
|
const loadLive = async () => {
|
||||||
|
if (!hoverOpen) {
|
||||||
|
setLiveSrc('')
|
||||||
|
setLivePreparing(false)
|
||||||
|
setLiveReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLivePreparing(true)
|
||||||
|
setLiveReady(false)
|
||||||
|
|
||||||
|
const cookieHeader = buildChaturbateCookieHeader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
|
||||||
|
const prepareRes = await fetch(
|
||||||
|
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&prepare=1`,
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ac.signal,
|
||||||
|
headers: cookieHeader
|
||||||
|
? { 'X-Chaturbate-Cookie': cookieHeader }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (ac.signal.aborted) return
|
||||||
|
|
||||||
|
// Auch wenn prepare fehlschlägt:
|
||||||
|
// best effort -> Stream-Endpunkt trotzdem versuchen
|
||||||
|
await prepareRes.json().catch(() => null)
|
||||||
|
|
||||||
|
if (ac.signal.aborted) return
|
||||||
|
|
||||||
|
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
|
||||||
|
} catch {
|
||||||
|
if (ac.signal.aborted) return
|
||||||
|
|
||||||
|
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
|
||||||
|
} finally {
|
||||||
|
if (!ac.signal.aborted) {
|
||||||
|
setLivePreparing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadLive()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ac.abort()
|
||||||
|
}
|
||||||
|
}, [hoverOpen, jobId, liveStreamSrc])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -117,6 +197,14 @@ export default function ModelPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setHoverOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setLiveSrc('')
|
||||||
|
setLivePreparing(false)
|
||||||
|
setLiveReady(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
content={(open, { close }) =>
|
content={(open, { close }) =>
|
||||||
open && (
|
open && (
|
||||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||||
@ -126,10 +214,16 @@ export default function ModelPreview({
|
|||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<LiveVideo
|
<LiveVideo
|
||||||
src={hq}
|
src={liveSrc}
|
||||||
muted={popupMuted}
|
muted={popupMuted}
|
||||||
volume={popupVolume}
|
volume={popupVolume}
|
||||||
roomStatus={roomStatus}
|
roomStatus={roomStatus}
|
||||||
|
onLoadingStart={() => {
|
||||||
|
setLiveReady(false)
|
||||||
|
}}
|
||||||
|
onReady={() => {
|
||||||
|
setLiveReady(true)
|
||||||
|
}}
|
||||||
onVolumeChange={(nextVolume, nextMuted) => {
|
onVolumeChange={(nextVolume, nextMuted) => {
|
||||||
setPopupVolume(nextVolume)
|
setPopupVolume(nextVolume)
|
||||||
setPopupMuted(nextMuted)
|
setPopupMuted(nextMuted)
|
||||||
@ -137,6 +231,15 @@ export default function ModelPreview({
|
|||||||
className="w-full h-full object-contain object-center relative z-0"
|
className="w-full h-full object-contain object-center relative z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{livePreparing || !liveReady ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
|
||||||
|
<LoadingSpinner />
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showLiveBadge ? (
|
{showLiveBadge ? (
|
||||||
<div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
<div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export type FinishedPostworkBadge = {
|
|||||||
running?: number
|
running?: number
|
||||||
maxParallel?: number
|
maxParallel?: number
|
||||||
ts: number
|
ts: number
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FinishedPostworkStepSummary = {
|
export type FinishedPostworkStepSummary = {
|
||||||
@ -57,6 +58,7 @@ export type FinishedPostworkStepSummary = {
|
|||||||
waiting?: number
|
waiting?: number
|
||||||
running?: number
|
running?: number
|
||||||
maxParallel?: number
|
maxParallel?: number
|
||||||
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FinishedPostworkSummary = {
|
export type FinishedPostworkSummary = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user