updated old version
This commit is contained in:
parent
078c60548c
commit
fb4f196994
@ -163,9 +163,10 @@ type EnsureAssetsResult struct {
|
||||
}
|
||||
|
||||
type EnsurePrimaryAssetsResult struct {
|
||||
Skipped bool
|
||||
ThumbGenerated bool
|
||||
MetaOK bool
|
||||
Skipped bool
|
||||
ThumbGenerated bool
|
||||
PreviewGenerated bool
|
||||
MetaOK bool
|
||||
}
|
||||
|
||||
// Public wrappers (kompatibel zu deinem bisherigen API)
|
||||
@ -229,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
return out, nil
|
||||
}
|
||||
|
||||
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
|
||||
_, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
|
||||
if perr != nil {
|
||||
return out, perr
|
||||
}
|
||||
@ -254,17 +255,26 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
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)
|
||||
out.MetaOK = meta.ok
|
||||
|
||||
if thumbBefore {
|
||||
if thumbBefore && previewBefore {
|
||||
out.Skipped = true
|
||||
progress(1)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const thumbsW = 1.0
|
||||
const (
|
||||
thumbsW = 0.25
|
||||
previewW = 0.75
|
||||
)
|
||||
|
||||
progress(0)
|
||||
|
||||
@ -318,8 +328,96 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
|
||||
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
|
||||
}
|
||||
|
||||
@ -356,6 +454,82 @@ func ensureMetaForVideoCtx(
|
||||
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(
|
||||
ctx context.Context,
|
||||
videoPath string,
|
||||
@ -409,9 +583,14 @@ func ensureTeaserForVideoCtx(
|
||||
nil,
|
||||
); err != nil {
|
||||
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
|
||||
|
||||
@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@ -89,6 +90,96 @@ func resolveChaturbateURL(m WatchedModelLite) string {
|
||||
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
|
||||
func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if store == nil {
|
||||
@ -99,26 +190,31 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// aggressiver vermeiden für "nicht in API, aber watched" Blind-Try
|
||||
const blindRetryCooldown = 2 * time.Minute
|
||||
|
||||
// wie lange wir warten, ob eine echte Datei entsteht
|
||||
const outputProbeMax = 20 * time.Second
|
||||
|
||||
queue := make([]autoStartItem, 0, 64)
|
||||
queued := map[string]bool{}
|
||||
lastTry := map[string]time.Time{}
|
||||
lastBlindTry := map[string]time.Time{}
|
||||
|
||||
var lastStart time.Time
|
||||
|
||||
for {
|
||||
if isAutostartPaused() {
|
||||
// optional: Queue behalten oder leeren – ich würde sie behalten.
|
||||
time.Sleep(2 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
queue = queue[:0]
|
||||
queued = map[string]bool{}
|
||||
@ -127,7 +223,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
}
|
||||
|
||||
cookieHdr := cookieHeaderFromSettings(s)
|
||||
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
|
||||
if !hasChaturbateCookies(cookieHdr) {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
@ -139,11 +234,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
cbMu.RUnlock()
|
||||
|
||||
showByUser := map[string]string{}
|
||||
seenInAPI := map[string]bool{}
|
||||
|
||||
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{}
|
||||
jobsMu.RLock()
|
||||
for _, j := range jobs {
|
||||
@ -178,20 +280,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if running[it.userKey] {
|
||||
continue
|
||||
}
|
||||
|
||||
it.url = resolveChaturbateURL(m)
|
||||
if it.url == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist
|
||||
nextQueue = append(nextQueue, it)
|
||||
nextQueued[it.userKey] = true
|
||||
}
|
||||
queue = nextQueue
|
||||
queued = nextQueued
|
||||
|
||||
// enqueue new public watched
|
||||
now := time.Now()
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 1) watched + API sagt public => normal enqueue
|
||||
// ------------------------------------------------------------
|
||||
for user, m := range watchedByUser {
|
||||
if showByUser[user] != "public" {
|
||||
continue
|
||||
@ -210,18 +315,78 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
queue = append(queue, autoStartItem{userKey: user, url: u})
|
||||
|
||||
queue = append(queue, autoStartItem{
|
||||
userKey: user,
|
||||
url: u,
|
||||
})
|
||||
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) {
|
||||
it := queue[0]
|
||||
queue = queue[1:]
|
||||
delete(queued, it.userKey)
|
||||
lastTry[it.userKey] = time.Now()
|
||||
|
||||
_, err := startRecordingInternal(RecordRequest{
|
||||
job, err := startRecordingInternal(RecordRequest{
|
||||
URL: it.url,
|
||||
Cookie: cookieHdr,
|
||||
})
|
||||
@ -233,6 +398,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||
if verboseLogs() {
|
||||
fmt.Println("▶️ [autostart] started:", it.url)
|
||||
}
|
||||
|
||||
// optional auch hier absichern; schadet nicht
|
||||
if job != nil {
|
||||
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
|
||||
}
|
||||
|
||||
lastStart = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -92,25 +91,6 @@ var (
|
||||
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 {
|
||||
seen := map[string]bool{}
|
||||
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) ---
|
||||
|
||||
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
||||
@ -850,140 +669,6 @@ func hashKey(parts ...string) string {
|
||||
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) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -992,9 +677,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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)
|
||||
reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
|
||||
if reqUA == "" {
|
||||
@ -1263,15 +945,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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
|
||||
// ---------------------------
|
||||
|
||||
209
backend/live.go
209
backend/live.go
@ -5,6 +5,7 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@ -15,6 +16,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -39,6 +41,59 @@ import (
|
||||
// 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 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) {
|
||||
// ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries.
|
||||
// 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) {
|
||||
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
|
||||
// Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4).
|
||||
file := strings.TrimSpace(r.URL.Query().Get("file"))
|
||||
if file == "" {
|
||||
recordPreviewLiveFMP4(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst)
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||
@ -579,10 +748,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
jobsMu.RLock()
|
||||
job, ok := jobs[id]
|
||||
state := ""
|
||||
sourceURL := ""
|
||||
if ok && job != nil {
|
||||
state = strings.TrimSpace(job.PreviewState)
|
||||
sourceURL = strings.TrimSpace(job.SourceURL)
|
||||
}
|
||||
jobsMu.RUnlock()
|
||||
|
||||
@ -591,39 +758,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
username := extractUsername(sourceURL)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = refreshPreviewLiveSourceForJob(r, job)
|
||||
|
||||
// Nach möglichem Refresh alles nochmal sauber lesen
|
||||
jobsMu.RLock()
|
||||
|
||||
@ -7,15 +7,24 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
)
|
||||
|
||||
// --- DVR-ähnlicher Recorder-Ablauf ---
|
||||
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
|
||||
// RecordStream für Chaturbate:
|
||||
// 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(
|
||||
ctx context.Context,
|
||||
hc *HTTPClient,
|
||||
@ -26,26 +35,30 @@ func RecordStream(
|
||||
job *RecordJob,
|
||||
) error {
|
||||
username = strings.Trim(strings.TrimSpace(username), "/")
|
||||
if username == "" {
|
||||
return errors.New("leerer username")
|
||||
}
|
||||
|
||||
base := strings.TrimRight(domain, "/")
|
||||
pageURL := base + "/" + username + "/"
|
||||
|
||||
loadFreshHLS := func() (string, error) {
|
||||
loadFreshPlaylist := func() (*cbPlaylist, error) {
|
||||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("seite laden: %w", err)
|
||||
return nil, fmt.Errorf("seite laden: %w", err)
|
||||
}
|
||||
|
||||
hlsURL, err := ParseStream(body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stream-parsing: %w", err)
|
||||
return nil, fmt.Errorf("stream-parsing: %w", err)
|
||||
}
|
||||
|
||||
hlsURL = strings.TrimSpace(hlsURL)
|
||||
if hlsURL == "" {
|
||||
return "", errors.New("leere hls url")
|
||||
return nil, errors.New("leere hls url")
|
||||
}
|
||||
|
||||
finalURL, err := getWantedResolutionPlaylistWithHeaders(
|
||||
finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders(
|
||||
ctx,
|
||||
hlsURL,
|
||||
httpCookie,
|
||||
@ -53,13 +66,18 @@ func RecordStream(
|
||||
pageURL,
|
||||
)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@ -71,7 +89,7 @@ func RecordStream(
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
job.PreviewM3U8 = hlsURL
|
||||
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||
job.PreviewCookie = httpCookie
|
||||
job.PreviewUA = hc.userAgent
|
||||
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
|
||||
refreshes := 0
|
||||
|
||||
for {
|
||||
err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL)
|
||||
err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment)
|
||||
if err == nil {
|
||||
return nil
|
||||
break
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
@ -98,14 +165,14 @@ func RecordStream(
|
||||
shouldRefresh :=
|
||||
strings.Contains(msg, "403") ||
|
||||
strings.Contains(msg, "401") ||
|
||||
strings.Contains(msg, "invalid data found") ||
|
||||
strings.Contains(msg, "error opening input") ||
|
||||
strings.Contains(msg, "playlist nicht mehr erreichbar") ||
|
||||
strings.Contains(msg, "fehlerhafte playlist") ||
|
||||
strings.Contains(msg, "keine neuen hls-segmente") ||
|
||||
strings.Contains(msg, "stream-parsing") ||
|
||||
strings.Contains(msg, "kein hls-quell-url") ||
|
||||
strings.Contains(msg, "http ")
|
||||
|
||||
if !shouldRefresh {
|
||||
return err
|
||||
return fmt.Errorf("watch segments: %w", err)
|
||||
}
|
||||
|
||||
refreshes++
|
||||
@ -125,7 +192,7 @@ func RecordStream(
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
}
|
||||
|
||||
newHLS, rerr := loadFreshHLS()
|
||||
newPlaylist, rerr := loadFreshPlaylist()
|
||||
if rerr != nil {
|
||||
fmt.Println("⚠️ [cb] refresh failed:",
|
||||
"user=", username,
|
||||
@ -141,16 +208,25 @@ func RecordStream(
|
||||
continue
|
||||
}
|
||||
|
||||
hlsURL = newHLS
|
||||
playlist = newPlaylist
|
||||
|
||||
if job != nil {
|
||||
jobsMu.Lock()
|
||||
job.PreviewM3U8 = hlsURL
|
||||
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||||
job.PreviewCookie = httpCookie
|
||||
job.PreviewUA = hc.userAgent
|
||||
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)
|
||||
@ -160,7 +236,6 @@ func ParseStream(html string) (string, error) {
|
||||
return "", errors.New("room dossier nicht gefunden")
|
||||
}
|
||||
|
||||
// DVR-Style Unicode-Decode
|
||||
decoded, err := strconv.Unquote(
|
||||
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
|
||||
)
|
||||
@ -180,7 +255,213 @@ func ParseStream(html string) (string, error) {
|
||||
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) {
|
||||
if cookieStr == "" {
|
||||
return
|
||||
@ -203,14 +484,13 @@ func addCookiesFromString(req *http.Request, cookieStr string) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- helper ---
|
||||
// helper
|
||||
func extractUsername(input string) string {
|
||||
s := strings.TrimSpace(input)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Falls volle URL
|
||||
if u, err := url.Parse(s); err == nil && u != nil {
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
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, "http://")
|
||||
s = strings.TrimPrefix(s, "www.")
|
||||
@ -239,9 +518,8 @@ func extractUsername(input string) string {
|
||||
func hasChaturbateCookies(cookieStr string) bool {
|
||||
m := parseCookieString(cookieStr)
|
||||
_, hasCF := m["cf_clearance"]
|
||||
// akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower)
|
||||
_, hasSessID := m["session_id"]
|
||||
_, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt
|
||||
_, hasSessIdAlt := m["sessionid"]
|
||||
return hasCF && (hasSessID || hasSessIdAlt)
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -64,7 +65,10 @@ func getWantedResolutionPlaylistWithHeaders(
|
||||
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 bestHeight int
|
||||
@ -79,7 +83,7 @@ func getWantedResolutionPlaylistWithHeaders(
|
||||
if v.Resolution != "" {
|
||||
parts := strings.Split(v.Resolution, "x")
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -90,10 +94,10 @@ func getWantedResolutionPlaylistWithHeaders(
|
||||
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
|
||||
bestFramerate = fr
|
||||
bestURI = strings.TrimSpace(v.URI)
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,12 +109,15 @@ func getWantedResolutionPlaylistWithHeaders(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
refURL, err := url.Parse(bestURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return baseURL.ResolveReference(refURL).String(), nil
|
||||
finalURL := baseURL.ResolveReference(refURL).String()
|
||||
|
||||
return finalURL, nil
|
||||
}
|
||||
|
||||
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
|
||||
@ -153,34 +160,6 @@ func handleM3U8Mode(
|
||||
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) == "" {
|
||||
return errors.New("output file path leer")
|
||||
}
|
||||
@ -214,13 +193,34 @@ func handleM3U8Mode(
|
||||
args = append(args, "-headers", hdr)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile)))
|
||||
|
||||
args = append(args,
|
||||
"-i", m3u8URL,
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-dn",
|
||||
"-ignore_unknown",
|
||||
"-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...)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
@ -246,6 +246,7 @@ func handleM3U8Mode(
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
sz := fi.Size()
|
||||
if sz > 0 && sz != last {
|
||||
jobsMu.Lock()
|
||||
@ -270,5 +271,14 @@ func handleM3U8Mode(
|
||||
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
|
||||
}
|
||||
|
||||
@ -483,7 +483,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||
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)
|
||||
recordDir := strings.TrimSpace(recordDirAbs)
|
||||
@ -554,7 +555,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||
|
||||
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()
|
||||
existingOut := strings.TrimSpace(job.Output)
|
||||
@ -584,7 +585,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
_ = os.MkdirAll(recordDirAbs, 0o755)
|
||||
|
||||
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)
|
||||
|
||||
jobsMu.Lock()
|
||||
@ -642,9 +643,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// HARTE SCHRANKE 2: Output ungültig / leer vor Postwork
|
||||
// WICHTIG: bei JobStopped kurz warten, damit Windows/File-Flush
|
||||
// nach context canceled noch fertig werden kann
|
||||
// HARTE SCHRANKE 2: Output prüfen, aber gestoppte Downloads
|
||||
// nicht aggressiv wegwerfen
|
||||
// ------------------------------------------------------------
|
||||
{
|
||||
var (
|
||||
@ -653,12 +653,21 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
)
|
||||
|
||||
if target == JobStopped {
|
||||
fi, serr = waitForUsableOutput(out, 2*time.Second)
|
||||
fi, serr = waitForUsableOutput(out, 3*time.Second)
|
||||
} else {
|
||||
fi, serr = os.Stat(out)
|
||||
}
|
||||
|
||||
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)
|
||||
purgeDurationCacheForPath(out)
|
||||
|
||||
@ -808,6 +817,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
// 1) Erst nach done verschieben
|
||||
setPhase("moving", 10)
|
||||
|
||||
moved, err2 := moveToDoneDir(out)
|
||||
@ -819,12 +829,27 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
} else {
|
||||
job.Error = "moveToDoneDir returned empty path"
|
||||
}
|
||||
job.Output = out
|
||||
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: "moving",
|
||||
Label: "Verschieben nach done fehlgeschlagen",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
@ -847,7 +872,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
jobsMu.Unlock()
|
||||
notifyDoneChanged()
|
||||
|
||||
// 3) Datei nach move trotzdem kaputt
|
||||
// 2) Nach move prüfen
|
||||
{
|
||||
fi, serr := os.Stat(out)
|
||||
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
|
||||
setPhase("probe", 35)
|
||||
setPhase("probe", 60)
|
||||
{
|
||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
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
|
||||
setPhase("probe", 75)
|
||||
setPhase("probe", 85)
|
||||
{
|
||||
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
w, h, fps, perr := probeVideoProps(pctx, out)
|
||||
@ -950,11 +1075,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
job.PostWork = nil
|
||||
jobsMu.Unlock()
|
||||
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
if postTarget == JobStopped {
|
||||
_ = publishJob(job.ID)
|
||||
} else {
|
||||
jobsMu.Lock()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
|
||||
publishJobRemove(job)
|
||||
publishJobRemove(job)
|
||||
}
|
||||
|
||||
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
|
||||
File: finalFile,
|
||||
@ -1006,8 +1135,24 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
||||
job.Progress = 100
|
||||
job.PostWorkKey = ""
|
||||
job.PostWork = nil
|
||||
job.Error = "Nachbearbeitung konnte nicht eingeplant werden"
|
||||
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()
|
||||
delete(jobs, job.ID)
|
||||
jobsMu.Unlock()
|
||||
|
||||
@ -45,7 +45,6 @@ func main() {
|
||||
store := registerRoutes(mux, auth)
|
||||
|
||||
go startChaturbateOnlinePoller(store)
|
||||
go startChaturbateBioContextPoller()
|
||||
go startChaturbateAutoStartWorker(store)
|
||||
go startMyFreeCamsAutoStartWorker(store)
|
||||
go startDiskSpaceGuard()
|
||||
|
||||
@ -63,6 +63,7 @@ type finishedPostworkEvent struct {
|
||||
State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing"
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Position int `json:"position,omitempty"`
|
||||
Waiting int `json:"waiting,omitempty"`
|
||||
Running int `json:"running,omitempty"`
|
||||
|
||||
@ -156,6 +156,7 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
|
||||
phase,
|
||||
state,
|
||||
label,
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -60,6 +61,7 @@ func publishFinishedPostworkPhase(
|
||||
phase string,
|
||||
state string,
|
||||
label string,
|
||||
errMsg string,
|
||||
) {
|
||||
setRegenerateAssetsJobPhase(file, queue, phase, state, label)
|
||||
|
||||
@ -71,6 +73,7 @@ func publishFinishedPostworkPhase(
|
||||
State: state,
|
||||
Phase: phase,
|
||||
Label: label,
|
||||
Error: strings.TrimSpace(errMsg),
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if b, err := json.Marshal(ev); err == nil {
|
||||
@ -320,18 +323,24 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
|
||||
setRegenerateAssetsTaskError(file, "Abgebrochen")
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "", "")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "", "")
|
||||
|
||||
removeRegenerateAssetsJobLater(file, 2*time.Second)
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler")
|
||||
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler", msg)
|
||||
}
|
||||
|
||||
finishRegenerateAssetsJob(file, "error", msg)
|
||||
@ -345,11 +354,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
}
|
||||
|
||||
// Initialstatus
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
||||
|
||||
// Alte Assets löschen
|
||||
if id != "" {
|
||||
@ -376,12 +385,12 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
return
|
||||
}
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta", "")
|
||||
|
||||
// 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 {
|
||||
cancelJob()
|
||||
return
|
||||
@ -395,10 +404,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
return
|
||||
}
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild", "")
|
||||
|
||||
// 3) Teaser
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser", "")
|
||||
|
||||
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
@ -414,10 +423,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
return
|
||||
}
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser", "")
|
||||
|
||||
// 4) Sprites
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites", "")
|
||||
|
||||
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
|
||||
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
@ -433,10 +442,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
return
|
||||
}
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
|
||||
|
||||
// 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 errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
|
||||
@ -452,7 +461,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
||||
return
|
||||
}
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "")
|
||||
|
||||
finishRegenerateAssetsJob(file, "done", "")
|
||||
setRegenerateAssetsTaskDone(file, "Fertig")
|
||||
@ -590,11 +599,11 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
enqueueRegenerateAssetsJob(job)
|
||||
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
|
||||
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
|
||||
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
|
||||
@ -93,6 +93,7 @@ type JobEvent = {
|
||||
modelImageUrl?: string
|
||||
modelChatRoomUrl?: string
|
||||
ts?: number
|
||||
error?: string
|
||||
|
||||
postWorkKey?: string
|
||||
postWork?: {
|
||||
@ -124,18 +125,6 @@ type TaskStateEvent = {
|
||||
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 = {
|
||||
recordDir: 'records',
|
||||
doneDir: 'records/done',
|
||||
@ -213,39 +202,6 @@ type AutostartState = {
|
||||
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' {
|
||||
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
|
||||
if (ok && next.pendingKeyLower) {
|
||||
const kLower = 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)
|
||||
)
|
||||
removePendingAutoStart(next.pendingKeyLower)
|
||||
}
|
||||
} finally {
|
||||
// dedupe wieder freigeben
|
||||
@ -1772,6 +1717,53 @@ export default function App() {
|
||||
return () => window.removeEventListener('models-changed', onChanged as any)
|
||||
}, [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 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<
|
||||
string,
|
||||
{
|
||||
@ -1923,20 +1909,30 @@ export default function App() {
|
||||
}
|
||||
> = {}
|
||||
|
||||
for (const room of rooms) {
|
||||
const key = String(room?.username ?? '').trim().toLowerCase()
|
||||
if (!key) continue
|
||||
if (res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
|
||||
|
||||
byKey[key] = {
|
||||
show: normalizePendingShow(room?.current_show),
|
||||
imageUrl: String(room?.image_url ?? '').trim() || undefined,
|
||||
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
|
||||
for (const room of rooms) {
|
||||
const key = String(room?.username ?? '').trim().toLowerCase()
|
||||
if (!key) continue
|
||||
|
||||
byKey[key] = {
|
||||
show: normalizePendingShow(room?.current_show),
|
||||
imageUrl: String(room?.image_url ?? '').trim() || undefined,
|
||||
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nicht gelieferte Keys => offline
|
||||
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, {
|
||||
imageUrl: snap.imageUrl,
|
||||
@ -1944,16 +1940,24 @@ export default function App() {
|
||||
|
||||
applyPendingRoomSnapshot(key, snap)
|
||||
|
||||
if (snap.show === 'public') {
|
||||
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
||||
if (!url) continue
|
||||
const url = String((pendingMap as any)?.[key] ?? '').trim()
|
||||
if (!url) continue
|
||||
|
||||
if (snap.show === 'public') {
|
||||
enqueueStart({
|
||||
url,
|
||||
silent: true,
|
||||
pendingKeyLower: key,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (snap.show === 'offline') {
|
||||
removePendingAutoStart(key)
|
||||
continue
|
||||
}
|
||||
|
||||
// private / hidden / away / unknown bleiben in der Warteschlange
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@ -1976,7 +1980,13 @@ export default function App() {
|
||||
cancelled = true
|
||||
if (timer != null) window.clearTimeout(timer)
|
||||
}
|
||||
}, [recSettings.useChaturbateApi, applyPendingRoomSnapshot, enqueueStart])
|
||||
}, [
|
||||
recSettings.useChaturbateApi,
|
||||
applyPendingRoomSnapshot,
|
||||
enqueueStart,
|
||||
maybeNotifyWatchedModelStatusChange,
|
||||
removePendingAutoStart,
|
||||
])
|
||||
|
||||
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
||||
|
||||
@ -2104,39 +2114,16 @@ export default function App() {
|
||||
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
|
||||
|
||||
if (mkLower) {
|
||||
// silent bleibt günstig wie bisher
|
||||
// silent: direkt in Queue legen, Status kommt später über den Poller
|
||||
if (silent) {
|
||||
setPendingAutoStartByKey((prev) => {
|
||||
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]
|
||||
})
|
||||
|
||||
queuePendingAutoStart(mkLower, norm, 'unknown')
|
||||
return true
|
||||
}
|
||||
|
||||
// manuell: erst /online
|
||||
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
|
||||
let resolvedImageUrl: string | undefined
|
||||
let resolvedChatRoomUrl: string | undefined
|
||||
|
||||
try {
|
||||
const onlineRes = await fetch('/api/chaturbate/online', {
|
||||
method: 'POST',
|
||||
@ -2147,67 +2134,51 @@ export default function App() {
|
||||
body: JSON.stringify({ q: [mkLower] }),
|
||||
})
|
||||
|
||||
let handled = false
|
||||
|
||||
if (onlineRes.ok) {
|
||||
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||||
const room = Array.isArray(onlineData?.rooms)
|
||||
? onlineData!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
|
||||
? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
|
||||
: null
|
||||
|
||||
if (room) {
|
||||
applyPendingRoomSnapshot(mkLower, {
|
||||
show: normalizePendingShow(room.current_show),
|
||||
imageUrl: String(room.image_url ?? '').trim() || undefined,
|
||||
chatRoomUrl: String(room.chat_room_url ?? '').trim() || undefined,
|
||||
})
|
||||
handled = true
|
||||
resolvedShow = normalizePendingShow(room.current_show)
|
||||
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
|
||||
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (!handled) {
|
||||
const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies)
|
||||
applyPendingRoomSnapshot(mkLower, {
|
||||
show: bio.show,
|
||||
imageUrl: bio.imageUrl,
|
||||
chatRoomUrl: bio.chatRoomUrl,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// 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 {
|
||||
if (silent) {
|
||||
const mkLower = providerKeyLowerFromUrl(norm)
|
||||
if (mkLower) {
|
||||
setPendingAutoStartByKey((prev) => {
|
||||
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]
|
||||
})
|
||||
|
||||
queuePendingAutoStart(mkLower, norm, 'unknown')
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -2510,6 +2481,7 @@ export default function App() {
|
||||
state,
|
||||
phase: String(msg?.phase ?? '').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,
|
||||
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : 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 keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
|
||||
const url = String(pending?.url ?? '').trim()
|
||||
|
||||
// 1) Aus pendingAutoStartByKey entfernen
|
||||
if (keyLower) {
|
||||
setPendingAutoStartByKey((prev) => {
|
||||
const next = { ...(prev || {}) }
|
||||
delete next[keyLower]
|
||||
pendingAutoStartByKeyRef.current = next
|
||||
return next
|
||||
})
|
||||
removePendingAutoStart(keyLower)
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Aus sichtbarer Warteliste entfernen
|
||||
const url = String(pending?.url ?? '').trim()
|
||||
if (!url) return
|
||||
|
||||
setPendingWatchedRooms((prev) =>
|
||||
prev.filter((x) => {
|
||||
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
|
||||
})
|
||||
prev.filter((x) => String(x?.url ?? '').trim() !== url)
|
||||
)
|
||||
}, [])
|
||||
}, [removePendingAutoStart])
|
||||
|
||||
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
||||
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
XMarkIcon,
|
||||
ArchiveBoxIcon,
|
||||
TrashIcon,
|
||||
ClipboardDocumentIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import { type SwipeCardHandle } from './SwipeCard'
|
||||
import { flushSync } from 'react-dom'
|
||||
@ -245,6 +246,85 @@ function sleep(ms: number) {
|
||||
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 {
|
||||
if (err instanceof Error) return err.message || String(err)
|
||||
return String(err ?? '')
|
||||
@ -905,6 +985,7 @@ function summarizePostworkSteps(
|
||||
phase,
|
||||
label: labelForStep(queue, phase),
|
||||
state: badge.state,
|
||||
error: String(badge.error ?? '').trim() || undefined,
|
||||
ts: badge.ts,
|
||||
position: badge.position,
|
||||
waiting: badge.waiting,
|
||||
@ -1035,7 +1116,9 @@ function mergePostworkSummaries(
|
||||
step.state === 'queued' ||
|
||||
step.state === 'error'
|
||||
) {
|
||||
stepMap.set(step.key, step)
|
||||
if (!prev || (step.ts || 0) >= (prev.ts || 0)) {
|
||||
stepMap.set(step.key, step)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@ -1227,10 +1310,18 @@ export function renderFinishedPostworkBadge(
|
||||
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
|
||||
</>
|
||||
) : 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" />
|
||||
<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' ? (
|
||||
<>
|
||||
<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
|
||||
} catch (e: any) {
|
||||
console.error('regenerate-assets failed', {
|
||||
file: cleanFile,
|
||||
error: e?.message ? String(e.message) : String(e),
|
||||
})
|
||||
|
||||
setPostworkByFile((prev) => ({
|
||||
...prev,
|
||||
[cleanFile]: [
|
||||
@ -3188,6 +3284,7 @@ export default function FinishedDownloads({
|
||||
String(detail?.label ?? '').trim() === file
|
||||
? undefined
|
||||
: detail?.label,
|
||||
error: String(detail?.error ?? '').trim() || undefined,
|
||||
}
|
||||
|
||||
const cur = Array.isArray(prev[file]) ? prev[file] : []
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// frontend\src\components\ui\HoverPopover.tsx
|
||||
|
||||
'use client'
|
||||
|
||||
import {
|
||||
@ -16,20 +18,25 @@ type Pos = { left: number; top: number }
|
||||
type HoverPopoverAPI = { close: () => void }
|
||||
|
||||
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)
|
||||
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 popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
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 clearCloseTimeout = () => {
|
||||
@ -42,14 +49,14 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
const scheduleClose = () => {
|
||||
clearCloseTimeout()
|
||||
closeTimeoutRef.current = window.setTimeout(() => {
|
||||
setOpen(false)
|
||||
setOpenState(false)
|
||||
closeTimeoutRef.current = null
|
||||
}, 150) // 150ms „Gnadenzeit“ zum Rüberfahren
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const handleEnter = () => {
|
||||
clearCloseTimeout()
|
||||
setOpen(true)
|
||||
setOpenState(true)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
@ -58,7 +65,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
|
||||
const close = () => {
|
||||
clearCloseTimeout()
|
||||
setOpen(false)
|
||||
setOpenState(false)
|
||||
}
|
||||
|
||||
const computePos = () => {
|
||||
@ -67,25 +74,19 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
if (!trigger || !pop) return
|
||||
|
||||
const gap = 8
|
||||
const pad = 8 // Abstand zum Viewport-Rand
|
||||
const pad = 8
|
||||
const r = trigger.getBoundingClientRect()
|
||||
const pr = pop.getBoundingClientRect()
|
||||
|
||||
// Prefer: unten
|
||||
let top = r.bottom + gap
|
||||
const bottomOverflow = top + pr.height > window.innerHeight - pad
|
||||
|
||||
if (bottomOverflow) {
|
||||
// Versuch: oben
|
||||
const above = r.top - pr.height - gap
|
||||
if (above >= pad) top = above
|
||||
else {
|
||||
// Notfalls clamp ins Viewport
|
||||
top = Math.max(pad, window.innerHeight - pr.height - pad)
|
||||
}
|
||||
else top = Math.max(pad, window.innerHeight - pr.height - pad)
|
||||
}
|
||||
|
||||
// Left clamp
|
||||
let left = r.left
|
||||
if (left + pr.width > window.innerWidth - pad) {
|
||||
left = window.innerWidth - pr.width - pad
|
||||
@ -95,15 +96,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
setPos({ left, top })
|
||||
}
|
||||
|
||||
// Beim Öffnen: erst rendern, dann messen/positionieren
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const id = requestAnimationFrame(() => computePos())
|
||||
return () => cancelAnimationFrame(id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// Reposition bei Scroll/Resize solange offen
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onMove = () => requestAnimationFrame(() => computePos())
|
||||
@ -113,15 +111,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
window.removeEventListener('resize', onMove)
|
||||
window.removeEventListener('scroll', onMove, true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
// Cleanup für Timeout
|
||||
useEffect(() => {
|
||||
return () => clearCloseTimeout()
|
||||
}, [])
|
||||
|
||||
// Hilfsfunktion: content normalisieren
|
||||
const renderContent = () =>
|
||||
typeof content === 'function'
|
||||
? (content as any)(open, { close })
|
||||
@ -162,4 +157,4 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ export default function LiveVideo({
|
||||
className,
|
||||
roomStatus,
|
||||
onVolumeChange,
|
||||
onLoadingStart,
|
||||
onReady,
|
||||
}: {
|
||||
src: string
|
||||
muted?: boolean
|
||||
@ -19,6 +21,8 @@ export default function LiveVideo({
|
||||
className?: string
|
||||
roomStatus?: string
|
||||
onVolumeChange?: (volume: number, muted: boolean) => void
|
||||
onLoadingStart?: () => void
|
||||
onReady?: () => void
|
||||
}) {
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const [broken, setBroken] = useState(false)
|
||||
@ -56,8 +60,8 @@ export default function LiveVideo({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let watchdogTimer: number | null = null
|
||||
let readySent = false
|
||||
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
@ -77,28 +81,58 @@ export default function LiveVideo({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 1) src setzen (fMP4/MP4 stream, single HTTP response)
|
||||
hardReset()
|
||||
|
||||
if (!src) {
|
||||
setBroken(false)
|
||||
setBrokenReason(null)
|
||||
return
|
||||
}
|
||||
|
||||
onLoadingStart?.()
|
||||
|
||||
const markReady = () => {
|
||||
if (readySent) return
|
||||
readySent = true
|
||||
onReady?.()
|
||||
}
|
||||
|
||||
video.src = src
|
||||
video.load()
|
||||
video.play().catch(() => {})
|
||||
|
||||
// 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen
|
||||
let lastT = Date.now()
|
||||
const onTime = () => {
|
||||
lastT = Date.now()
|
||||
markReady()
|
||||
}
|
||||
|
||||
const onLoadedData = () => {
|
||||
markReady()
|
||||
}
|
||||
|
||||
const onCanPlay = () => {
|
||||
markReady()
|
||||
}
|
||||
|
||||
const onPlaying = () => {
|
||||
markReady()
|
||||
}
|
||||
|
||||
video.addEventListener('timeupdate', onTime)
|
||||
video.addEventListener('loadeddata', onLoadedData)
|
||||
video.addEventListener('canplay', onCanPlay)
|
||||
video.addEventListener('playing', onPlaying)
|
||||
|
||||
let stalledRetries = 0
|
||||
|
||||
watchdogTimer = window.setInterval(() => {
|
||||
if (cancelled) return
|
||||
|
||||
// wenn nicht paused, aber 12s kein Fortschritt -> reconnect versuchen
|
||||
if (!video.paused && Date.now() - lastT > 12_000) {
|
||||
if (stalledRetries < 1) {
|
||||
stalledRetries += 1
|
||||
readySent = false
|
||||
onLoadingStart?.()
|
||||
|
||||
hardReset()
|
||||
video.src = src
|
||||
video.load()
|
||||
@ -118,30 +152,30 @@ export default function LiveVideo({
|
||||
}
|
||||
}, 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 reason = normalizeBrokenReason(roomStatus)
|
||||
|
||||
if (reason === 'unknown') {
|
||||
// bei unbekanntem Raumstatus nicht sofort hart fehlschlagen
|
||||
return
|
||||
}
|
||||
|
||||
setBroken(true)
|
||||
setBrokenReason(reason)
|
||||
}
|
||||
|
||||
video.addEventListener('error', onError)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (watchdogTimer) window.clearInterval(watchdogTimer)
|
||||
watchdogTimer = null
|
||||
video.removeEventListener('timeupdate', onTime)
|
||||
video.removeEventListener('loadeddata', onLoadedData)
|
||||
video.removeEventListener('canplay', onCanPlay)
|
||||
video.removeEventListener('playing', onPlaying)
|
||||
video.removeEventListener('error', onError)
|
||||
hardReset()
|
||||
}
|
||||
}, [src, roomStatus])
|
||||
}, [src, roomStatus, muted, volume, onLoadingStart, onReady])
|
||||
|
||||
useEffect(() => {
|
||||
const video = ref.current
|
||||
|
||||
@ -9,9 +9,11 @@ import {
|
||||
SpeakerXMarkIcon,
|
||||
SpeakerWaveIcon,
|
||||
} from '@heroicons/react/24/outline'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
type Props = {
|
||||
jobId: string
|
||||
modelKey?: string
|
||||
live?: boolean
|
||||
thumbTick?: number
|
||||
autoTickMs?: number
|
||||
@ -22,6 +24,22 @@ type Props = {
|
||||
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({
|
||||
jobId,
|
||||
live = false,
|
||||
@ -38,6 +56,11 @@ export default function ModelPreview({
|
||||
const [pageVisible, setPageVisible] = useState(!document.hidden)
|
||||
const [localTick, setLocalTick] = useState(0)
|
||||
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 [popupVolume, setPopupVolume] = useState(1)
|
||||
@ -63,7 +86,7 @@ export default function ModelPreview({
|
||||
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
||||
}, [fallbackSrc, jobId])
|
||||
|
||||
const hq = useMemo(
|
||||
const liveStreamSrc = useMemo(
|
||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
||||
[jobId]
|
||||
)
|
||||
@ -74,6 +97,63 @@ export default function ModelPreview({
|
||||
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(() => {
|
||||
const el = rootRef.current
|
||||
if (!el) return
|
||||
@ -117,6 +197,14 @@ export default function ModelPreview({
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
onOpenChange={(open) => {
|
||||
setHoverOpen(open)
|
||||
if (!open) {
|
||||
setLiveSrc('')
|
||||
setLivePreparing(false)
|
||||
setLiveReady(false)
|
||||
}
|
||||
}}
|
||||
content={(open, { close }) =>
|
||||
open && (
|
||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||
@ -126,10 +214,16 @@ export default function ModelPreview({
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<LiveVideo
|
||||
src={hq}
|
||||
src={liveSrc}
|
||||
muted={popupMuted}
|
||||
volume={popupVolume}
|
||||
roomStatus={roomStatus}
|
||||
onLoadingStart={() => {
|
||||
setLiveReady(false)
|
||||
}}
|
||||
onReady={() => {
|
||||
setLiveReady(true)
|
||||
}}
|
||||
onVolumeChange={(nextVolume, nextMuted) => {
|
||||
setPopupVolume(nextVolume)
|
||||
setPopupMuted(nextMuted)
|
||||
@ -137,6 +231,15 @@ export default function ModelPreview({
|
||||
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 ? (
|
||||
<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" />
|
||||
|
||||
@ -44,6 +44,7 @@ export type FinishedPostworkBadge = {
|
||||
running?: number
|
||||
maxParallel?: number
|
||||
ts: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type FinishedPostworkStepSummary = {
|
||||
@ -57,6 +58,7 @@ export type FinishedPostworkStepSummary = {
|
||||
waiting?: number
|
||||
running?: number
|
||||
maxParallel?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type FinishedPostworkSummary = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user