updated old version

This commit is contained in:
Linrador 2026-04-07 07:20:08 +02:00
parent 078c60548c
commit fb4f196994
17 changed files with 1494 additions and 705 deletions

View File

@ -165,6 +165,7 @@ type EnsureAssetsResult struct {
type EnsurePrimaryAssetsResult struct { type EnsurePrimaryAssetsResult struct {
Skipped bool Skipped bool
ThumbGenerated bool ThumbGenerated bool
PreviewGenerated bool
MetaOK bool MetaOK bool
} }
@ -229,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return out, nil return out, nil
} }
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id) _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id)
if perr != nil { if perr != nil {
return out, perr return out, perr
} }
@ -254,17 +255,26 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
return false return false
}() }()
// Basis-Meta nur für Dauer/Props sicherstellen previewBefore := func() bool {
if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 {
return true
}
return false
}()
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
out.MetaOK = meta.ok out.MetaOK = meta.ok
if thumbBefore { if thumbBefore && previewBefore {
out.Skipped = true out.Skipped = true
progress(1) progress(1)
return out, nil return out, nil
} }
const thumbsW = 1.0 const (
thumbsW = 0.25
previewW = 0.75
)
progress(0) progress(0)
@ -318,8 +328,96 @@ func ensurePrimaryAssetsForVideoWithProgressCtx(
progress(thumbsW) progress(thumbsW)
} }
_ = sourceInputInvalid // ----------------
// Preview / Teaser
// ----------------
const (
previewClipLenSec = 0.75
previewMaxClips = 12
)
var computedPreviewClips []previewClip
if previewBefore {
progress(1) progress(1)
} else {
func() {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
progress(thumbsW + 0.02)
if err := genSem.Acquire(genCtx); err != nil {
return
}
defer genSem.Release()
progress(thumbsW + 0.05)
if err := generateTeaserClipsMP4WithProgress(
genCtx,
videoPath,
previewPath,
previewClipLenSec,
previewMaxClips,
func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
progress(thumbsW + r*previewW)
},
); err != nil {
if isFFmpegInputInvalidError(err) {
sourceInputInvalid = true
return
}
return
}
out.PreviewGenerated = true
if !(meta.durSec > 0) {
return
}
opts := TeaserPreviewOptions{
Segments: previewMaxClips,
SegmentDuration: previewClipLenSec,
}
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
clips := make([]previewClip, 0, len(starts))
for _, s := range starts {
clips = append(clips, previewClip{
StartSeconds: math.Round(s*1000) / 1000,
DurationSeconds: math.Round(segDur*1000) / 1000,
})
}
computedPreviewClips = clips
}()
progress(1)
}
// Meta mit Preview-Clips aktualisieren (best effort)
if meta.durSec > 0 && !sourceInputInvalid {
_ = writeVideoMetaWithPreviewClipsAndSprite(
metaPath,
fi,
meta.durSec,
meta.vw,
meta.vh,
meta.fps,
meta.sourceURL,
computedPreviewClips,
nil,
)
}
return out, nil return out, nil
} }
@ -356,6 +454,82 @@ func ensureMetaForVideoCtx(
return meta.ok, nil return meta.ok, nil
} }
func ensureThumbForVideoCtx(
ctx context.Context,
videoPath string,
sourceURL string,
) (bool, error) {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return false, nil
}
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return false, nil
}
id := assetIDFromVideoPath(videoPath)
if id == "" {
return false, nil
}
_, thumbPath, _, _, metaPath, perr := assetPathsForID(id)
if perr != nil {
return false, perr
}
// Schon vorhanden -> nichts zu tun
if tfi, err := os.Stat(thumbPath); err == nil && tfi != nil && !tfi.IsDir() && tfi.Size() > 0 {
return false, nil
}
// Meta best effort sicherstellen, damit wir Duration für den "kurz vor Ende"-Fallback haben
meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi)
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
if err := thumbSem.Acquire(genCtx); err != nil {
return false, err
}
defer thumbSem.Release()
// Immer letztes Frame bevorzugen
img, e1 := extractLastFrameJPG(videoPath)
if e1 != nil || len(img) == 0 {
// Fallback: kurz vor Ende
if meta.durSec > 0 {
t := meta.durSec - 0.25
if t < 0 {
t = 0
}
img, e1 = extractFrameAtTimeJPG(videoPath, t)
}
// Letzter Fallback: erstes Frame
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75)
}
}
if e1 != nil {
if isFFmpegInputInvalidError(e1) {
return false, nil
}
return false, e1
}
if len(img) == 0 {
return false, nil
}
if err := atomicWriteFile(thumbPath, img); err != nil {
return false, err
}
return true, nil
}
func ensureTeaserForVideoCtx( func ensureTeaserForVideoCtx(
ctx context.Context, ctx context.Context,
videoPath string, videoPath string,
@ -409,9 +583,14 @@ func ensureTeaserForVideoCtx(
nil, nil,
); err != nil { ); err != nil {
if isFFmpegInputInvalidError(err) { if isFFmpegInputInvalidError(err) {
return false, nil return false, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err)
} }
return false, err return false, fmt.Errorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err)
}
// Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren
if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 {
return false, fmt.Errorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath)
} }
var computedPreviewClips []previewClip var computedPreviewClips []previewClip

View File

@ -5,6 +5,7 @@ package main
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"os"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -89,6 +90,96 @@ func resolveChaturbateURL(m WatchedModelLite) string {
return fmt.Sprintf("https://chaturbate.com/%s/", key) return fmt.Sprintf("https://chaturbate.com/%s/", key)
} }
func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
jobsMu.RLock()
job := jobs[jobID]
status := JobStatus("")
out := ""
hidden := false
if job != nil {
status = job.Status
out = strings.TrimSpace(job.Output)
hidden = job.Hidden
}
jobsMu.RUnlock()
// Job schon weg oder nicht mehr running -> nichts tun
if job == nil || status != JobRunning {
return
}
// echte Output-Datei vorhanden?
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
// hidden blind-try wird jetzt sichtbar gemacht
if hidden {
jobsMu.Lock()
j := jobs[jobID]
if j != nil {
j.Hidden = false
}
jobsMu.Unlock()
}
publishJob(jobID)
return
}
}
time.Sleep(900 * time.Millisecond)
}
// nach Wartezeit immer noch keine Datei => stoppen + löschen
jobsMu.Lock()
job := jobs[jobID]
if job == nil || job.Status != JobRunning {
jobsMu.Unlock()
return
}
pc := job.previewCmd
job.previewCmd = nil
previewCancel := job.previewCancel
job.previewCancel = nil
recordCancel := job.cancel
out := strings.TrimSpace(job.Output)
wasVisible := !job.Hidden
jobsMu.Unlock()
if previewCancel != nil {
previewCancel()
}
if pc != nil && pc.Process != nil {
_ = pc.Process.Kill()
}
if recordCancel != nil {
recordCancel()
}
// 0-Byte Datei ggf. löschen
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
_ = os.Remove(out)
}
}
jobsMu.Lock()
j := jobs[jobID]
delete(jobs, jobID)
jobsMu.Unlock()
if wasVisible && j != nil {
publishJobRemove(j)
}
}
// Startet watched+online(public) automatisch unabhängig vom Frontend // Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) { func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil { if store == nil {
@ -99,26 +190,31 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
} }
const pollInterval = 5 * time.Second const pollInterval = 5 * time.Second
const startGap = 5 * time.Second const startGap = 1500 * time.Millisecond
// normaler Retry für API-public
const retryCooldown = 25 * time.Second const retryCooldown = 25 * time.Second
// aggressiver vermeiden für "nicht in API, aber watched" Blind-Try
const blindRetryCooldown = 2 * time.Minute
// wie lange wir warten, ob eine echte Datei entsteht
const outputProbeMax = 20 * time.Second
queue := make([]autoStartItem, 0, 64) queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{} queued := map[string]bool{}
lastTry := map[string]time.Time{} lastTry := map[string]time.Time{}
lastBlindTry := map[string]time.Time{}
var lastStart time.Time var lastStart time.Time
for { for {
if isAutostartPaused() { if isAutostartPaused() {
// optional: Queue behalten oder leeren ich würde sie behalten.
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
continue continue
} }
s := getSettings() s := getSettings()
// Backend-Autostart läuft, wenn
// - Chaturbate API aktiviert ist
// - Autostart NICHT pausiert ist (wird oben schon via isAutostartPaused() geprüft)
if !s.UseChaturbateAPI { if !s.UseChaturbateAPI {
queue = queue[:0] queue = queue[:0]
queued = map[string]bool{} queued = map[string]bool{}
@ -127,7 +223,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
} }
cookieHdr := cookieHeaderFromSettings(s) cookieHdr := cookieHeaderFromSettings(s)
// ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob)
if !hasChaturbateCookies(cookieHdr) { if !hasChaturbateCookies(cookieHdr) {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue
@ -139,11 +234,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
cbMu.RUnlock() cbMu.RUnlock()
showByUser := map[string]string{} showByUser := map[string]string{}
seenInAPI := map[string]bool{}
for _, r := range rooms { for _, r := range rooms {
showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) u := normUser(r.Username)
if u == "" {
continue
}
seenInAPI[u] = true
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
} }
// running users (damit wir nicht doppelt starten) // laufende Jobs sammeln
running := map[string]bool{} running := map[string]bool{}
jobsMu.RLock() jobsMu.RLock()
for _, j := range jobs { for _, j := range jobs {
@ -178,20 +280,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if running[it.userKey] { if running[it.userKey] {
continue continue
} }
it.url = resolveChaturbateURL(m) it.url = resolveChaturbateURL(m)
if it.url == "" { if it.url == "" {
continue continue
} }
// Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist
nextQueue = append(nextQueue, it) nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true nextQueued[it.userKey] = true
} }
queue = nextQueue queue = nextQueue
queued = nextQueued queued = nextQueued
// enqueue new public watched
now := time.Now() now := time.Now()
// ------------------------------------------------------------
// 1) watched + API sagt public => normal enqueue
// ------------------------------------------------------------
for user, m := range watchedByUser { for user, m := range watchedByUser {
if showByUser[user] != "public" { if showByUser[user] != "public" {
continue continue
@ -210,18 +315,78 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if u == "" { if u == "" {
continue continue
} }
queue = append(queue, autoStartItem{userKey: user, url: u})
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
queued[user] = true queued[user] = true
} }
// starte max. einen Job pro Loop (mit Abstand) // ------------------------------------------------------------
// 2) watched + NICHT in API => blind best-effort try wie MFC
// aber nur wenn:
// - nicht schon running
// - nicht schon queued
// - nicht kürzlich versucht
// ------------------------------------------------------------
for user, m := range watchedByUser {
if seenInAPI[user] {
continue
}
if running[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
continue
}
u := resolveChaturbateURL(m)
if u == "" {
continue
}
// direkt "blind" starten, nicht erst in queue hängen,
// damit das Verhalten wie MFC ist
if !lastStart.IsZero() && time.Since(lastStart) < startGap {
continue
}
lastBlindTry[user] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: u,
Cookie: cookieHdr,
Hidden: true,
})
if err != nil || job == nil {
if verboseLogs() {
fmt.Println("❌ [autostart] blind start failed:", u, err)
}
continue
}
if verboseLogs() {
fmt.Println("▶️ [autostart] blind try started:", u)
}
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
lastStart = time.Now()
}
// ------------------------------------------------------------
// 3) normale API-public queue abarbeiten
// ------------------------------------------------------------
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) { if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
it := queue[0] it := queue[0]
queue = queue[1:] queue = queue[1:]
delete(queued, it.userKey) delete(queued, it.userKey)
lastTry[it.userKey] = time.Now() lastTry[it.userKey] = time.Now()
_, err := startRecordingInternal(RecordRequest{ job, err := startRecordingInternal(RecordRequest{
URL: it.url, URL: it.url,
Cookie: cookieHdr, Cookie: cookieHdr,
}) })
@ -233,6 +398,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if verboseLogs() { if verboseLogs() {
fmt.Println("▶️ [autostart] started:", it.url) fmt.Println("▶️ [autostart] started:", it.url)
} }
// optional auch hier absichern; schadet nicht
if job != nil {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
}
lastStart = time.Now() lastStart = time.Now()
} }
} }

View File

@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -92,25 +91,6 @@ var (
cbRefreshInFlight bool cbRefreshInFlight bool
) )
// --- HLS refresh throttling (damit /online nicht zu teuer wird) ---
var cbHlsRefreshMu sync.Mutex
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
if userLower == "" {
return false
}
cbHlsRefreshMu.Lock()
defer cbHlsRefreshMu.Unlock()
last := cbHlsRefreshAt[userLower]
if !last.IsZero() && time.Since(last) < minInterval {
return false
}
cbHlsRefreshAt[userLower] = time.Now()
return true
}
func normalizeList(in []string) []string { func normalizeList(in []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
out := make([]string, 0, len(in)) out := make([]string, 0, len(in))
@ -367,167 +347,6 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
} }
} }
var cbBioRefreshMu sync.Mutex
var cbBioRefreshAt = map[string]time.Time{} // key=userLower -> last biocontext refresh
func shouldRefreshBio(userLower string, minInterval time.Duration) bool {
if userLower == "" {
return false
}
cbBioRefreshMu.Lock()
defer cbBioRefreshMu.Unlock()
last := cbBioRefreshAt[userLower]
if !last.IsZero() && time.Since(last) < minInterval {
return false
}
cbBioRefreshAt[userLower] = time.Now()
return true
}
func activeChaturbateJobsMissingFromOnlineSnapshot() map[string]string {
out := map[string]string{}
cbMu.RLock()
lite := cb.LiteByUser
cbMu.RUnlock()
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isActiveRecordingJob(j) {
continue
}
if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" {
continue
}
userLower := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL)))
if userLower == "" {
continue
}
if lite != nil {
if _, ok := lite[userLower]; ok {
continue // schon im /online Snapshot
}
}
cookie := strings.TrimSpace(j.PreviewCookie)
out[userLower] = cookie
}
return out
}
func refreshChaturbateStatusFromBioContext(ctx context.Context, model string, cookieHeader string) error {
model = sanitizeModelKey(model)
if model == "" {
return fmt.Errorf("empty model")
}
u := fmt.Sprintf(chaturbateBioContextURLFmt, model)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Referer", "https://chaturbate.com/"+model+"/")
if ck := strings.TrimSpace(cookieHeader); ck != "" {
req.Header.Set("Cookie", ck)
}
resp, err := cbHTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("biocontext HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
var bio any
if err := json.Unmarshal(raw, &bio); err != nil {
return err
}
fetchedAt := time.Now().UTC()
if cbModelStore != nil {
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
}
show, chatRoomURL, imageURL, ok := inferRoomStatusFromBio(bio)
if !ok {
return nil
}
isOnline := show != "offline" && show != "unknown"
if cbModelStore != nil {
_ = cbModelStore.SetChaturbateRoomState(
"chaturbate.com",
model,
show,
isOnline,
chatRoomURL,
imageURL,
fetchedAt,
)
}
return nil
}
func startChaturbateBioContextPoller() {
const scanEvery = 15 * time.Second
const minPerModel = 1 * time.Minute
ticker := time.NewTicker(scanEvery)
defer ticker.Stop()
for range ticker.C {
if !getSettings().UseChaturbateAPI {
continue
}
missing := activeChaturbateJobsMissingFromOnlineSnapshot()
if len(missing) == 0 {
continue
}
for userLower, cookieHeader := range missing {
if !shouldRefreshBio(userLower, minPerModel) {
continue
}
go func(model, cookie string) {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
if err := refreshChaturbateStatusFromBioContext(ctx, model, cookie); err != nil {
fmt.Println("⚠️ [chaturbate] biocontext refresh failed:", model, err)
}
}(userLower, cookieHeader)
}
}
}
// --- Profilbild Download + Persist (online -> offline) --- // --- Profilbild Download + Persist (online -> offline) ---
func selectBestRoomImageURL(rm ChaturbateRoom) string { func selectBestRoomImageURL(rm ChaturbateRoom) string {
@ -850,140 +669,6 @@ func hashKey(parts ...string) string {
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// jobMatchesUser prüft, ob ein laufender Job zu diesem Username gehört.
// (wir matchen über SourceURL und Output-Pfad robust genug ohne modelNameFromFilename Abhängigkeit)
func jobMatchesUser(j *RecordJob, userLower string) bool {
if j == nil {
return false
}
u := strings.ToLower(strings.TrimSpace(userLower))
if u == "" {
return false
}
// 1) SourceURL enthält meist /<username>
if s := strings.ToLower(strings.TrimSpace(j.SourceURL)); s != "" {
if strings.Contains(s, "/"+u) || strings.HasSuffix(s, "/"+u) || strings.HasSuffix(s, u) {
return true
}
}
// 2) Output-Pfad enthält bei dir häufig den modelKey im Dateinamen/Ordner
if out := strings.ToLower(strings.TrimSpace(j.Output)); out != "" {
base := strings.ToLower(strings.TrimSpace(filepath.Base(out)))
if strings.Contains(base, u) {
return true
}
dir := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(out))))
if dir == u {
return true
}
}
return false
}
// fetchCurrentBestHLS lädt die Room-Seite, parsed hls_source und wählt die beste Variant-Playlist.
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
u := strings.TrimSpace(username)
if u == "" {
return "", fmt.Errorf("empty username")
}
hc := NewHTTPClient(userAgent)
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
body, err := hc.FetchPage(ctx, pageURL, cookie)
if err != nil {
return "", err
}
master, err := ParseStream(body) // hls_source
if err != nil {
return "", err
}
return strings.TrimSpace(master), nil
}
// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs.
// Wenn die URL rotiert hat: stopPreview(job) damit ffmpeg neu startet.
func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua string) {
if strings.TrimSpace(userLower) == "" || strings.TrimSpace(newHls) == "" {
return
}
toStop := make([]*RecordJob, 0, 4)
jobsMu.Lock()
for _, j := range jobs {
if j == nil || j.Status != JobRunning {
continue
}
if !jobMatchesUser(j, userLower) {
continue
}
old := strings.TrimSpace(j.PreviewM3U8)
j.PreviewM3U8 = newHls
j.PreviewCookie = cookie
j.PreviewUA = ua
if old != "" && old != newHls {
j.PreviewState = ""
j.PreviewStateAt = ""
j.PreviewStateMsg = ""
toStop = append(toStop, j)
}
}
jobsMu.Unlock()
for _, j := range toStop {
stopPreview(j)
}
}
func refreshRunningJobsHLSAsync(users []string, liteByUser map[string]ChaturbateOnlineRoomLite, cookieHeader string, reqUA string) {
if len(users) == 0 || liteByUser == nil {
return
}
const hlsMinInterval = 12 * time.Second
liteCopy := make(map[string]ChaturbateOnlineRoomLite, len(liteByUser))
for k, v := range liteByUser {
liteCopy[k] = v
}
go func(usersCopy []string, rooms map[string]ChaturbateOnlineRoomLite, cookie string, ua string) {
for _, u := range usersCopy {
rm, ok := rooms[u]
if !ok {
continue
}
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if show == "" || show == "offline" {
continue
}
if !shouldRefreshHLS(u, hlsMinInterval) {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookie, ua)
cancel()
if err != nil || strings.TrimSpace(newHls) == "" {
continue
}
refreshRunningJobsHLS(u, newHls, cookie, ua)
}
}(append([]string(nil), users...), liteCopy, cookieHeader, reqUA)
}
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost { if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
@ -992,9 +677,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
enabled := getSettings().UseChaturbateAPI enabled := getSettings().UseChaturbateAPI
// Optional: Cookie vom Frontend (für Cloudflare/session best effort)
cookieHeader := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie"))
// UA vom Client (oder fallback) // UA vom Client (oder fallback)
reqUA := strings.TrimSpace(r.Header.Get("User-Agent")) reqUA := strings.TrimSpace(r.Header.Get("User-Agent"))
if reqUA == "" { if reqUA == "" {
@ -1263,15 +945,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
needBootstrap = fetchedAt.IsZero() needBootstrap = fetchedAt.IsZero()
// ---------------------------
// ✅ HLS URL Refresh für laufende Jobs (best effort)
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
// und nur wenn User gerade online ist.
// ---------------------------
if onlySpecificUsers && liteByUser != nil {
refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA)
}
// --------------------------- // ---------------------------
// Persist "last seen online/offline" für explizit angefragte User // Persist "last seen online/offline" für explizit angefragte User
// --------------------------- // ---------------------------

View File

@ -5,6 +5,7 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -15,6 +16,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"sync"
"time" "time"
) )
@ -39,6 +41,59 @@ import (
// Allowed files that may be served out of PreviewDir. // Allowed files that may be served out of PreviewDir.
var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts|init\.m4s|\w+\.m4s)$`) var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts|init\.m4s|\w+\.m4s)$`)
var cbHlsRefreshMu sync.Mutex
var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time
func shouldRefreshHLS(userLower string, minInterval time.Duration) bool {
userLower = strings.ToLower(strings.TrimSpace(userLower))
if userLower == "" {
return false
}
cbHlsRefreshMu.Lock()
defer cbHlsRefreshMu.Unlock()
last := cbHlsRefreshAt[userLower]
if !last.IsZero() && time.Since(last) < minInterval {
return false
}
cbHlsRefreshAt[userLower] = time.Now()
return true
}
// fetchCurrentBestHLS lädt die Room-Seite und parsed hls_source.
func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) {
u := strings.TrimSpace(username)
if u == "" {
return "", fmt.Errorf("empty username")
}
if strings.TrimSpace(userAgent) == "" {
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
hc := NewHTTPClient(userAgent)
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
body, err := hc.FetchPage(ctx, pageURL, cookie)
if err != nil {
return "", err
}
master, err := ParseStream(body)
if err != nil {
return "", err
}
master = strings.TrimSpace(master)
if master == "" {
return "", fmt.Errorf("empty parsed hls url")
}
return master, nil
}
func serveLiveNotReady(w http.ResponseWriter, r *http.Request) { func serveLiveNotReady(w http.ResponseWriter, r *http.Request) {
// ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries. // ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries.
// Deshalb: IMMER 200 + gültige (aber leere) m3u8 zurückgeben. // Deshalb: IMMER 200 + gültige (aber leere) m3u8 zurückgeben.
@ -103,16 +158,130 @@ func stopPreview(job *RecordJob) {
} }
} }
func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error {
if job == nil {
return fmt.Errorf("job nil")
}
jobsMu.RLock()
sourceURL := strings.TrimSpace(job.SourceURL)
cookie := strings.TrimSpace(job.PreviewCookie)
ua := strings.TrimSpace(job.PreviewUA)
jobsMu.RUnlock()
if detectProvider(sourceURL) != "chaturbate" {
return nil
}
// Optional: Cookie vom aktuellen Request bevorzugen
if reqCookie := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")); reqCookie != "" {
cookie = reqCookie
}
// UA vom Request bevorzugen
if reqUA := strings.TrimSpace(r.Header.Get("User-Agent")); reqUA != "" {
ua = reqUA
}
if ua == "" {
ua = "Mozilla/5.0"
}
username := strings.TrimSpace(extractUsername(sourceURL))
if username == "" {
return fmt.Errorf("username not found")
}
// ✅ wichtig: doppelten Refresh verhindern
if !shouldRefreshHLS(strings.ToLower(username), 10*time.Second) {
return nil
}
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
defer cancelRefresh()
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
if err != nil {
return err
}
newHls = strings.TrimSpace(newHls)
if newHls == "" {
return fmt.Errorf("empty hls")
}
var oldHls string
jobsMu.Lock()
oldHls = strings.TrimSpace(job.PreviewM3U8)
job.PreviewM3U8 = newHls
job.PreviewCookie = cookie
job.PreviewUA = ua
job.PreviewState = ""
job.PreviewStateAt = ""
job.PreviewStateMsg = ""
jobsMu.Unlock()
if oldHls != "" && oldHls != newHls {
stopPreview(job)
}
return nil
}
func recordPreviewLive(w http.ResponseWriter, r *http.Request) { func recordPreviewLive(w http.ResponseWriter, r *http.Request) {
prepare := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("prepare")))
if prepare == "1" || prepare == "true" || prepare == "yes" {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
jobsMu.RLock()
job, ok := jobs[id]
jobsMu.RUnlock()
if !ok || job == nil {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
err := refreshPreviewLiveSourceForJob(r, job)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
type resp struct {
OK bool `json:"ok"`
Prepared bool `json:"prepared"`
FetchedAt time.Time `json:"fetchedAt"`
LastError string `json:"lastError,omitempty"`
}
if err != nil {
_ = json.NewEncoder(w).Encode(resp{
OK: false,
Prepared: false,
FetchedAt: time.Now(),
LastError: err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(resp{
OK: true,
Prepared: true,
FetchedAt: time.Now(),
})
return
}
// ✅ Route bleibt /api/preview/live // ✅ Route bleibt /api/preview/live
// Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4).
file := strings.TrimSpace(r.URL.Query().Get("file")) file := strings.TrimSpace(r.URL.Query().Get("file"))
if file == "" { if file == "" {
recordPreviewLiveFMP4(w, r) recordPreviewLiveFMP4(w, r)
return return
} }
// Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst)
id := strings.TrimSpace(r.URL.Query().Get("id")) id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" { if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest) http.Error(w, "id fehlt", http.StatusBadRequest)
@ -579,10 +748,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
jobsMu.RLock() jobsMu.RLock()
job, ok := jobs[id] job, ok := jobs[id]
state := "" state := ""
sourceURL := ""
if ok && job != nil { if ok && job != nil {
state = strings.TrimSpace(job.PreviewState) state = strings.TrimSpace(job.PreviewState)
sourceURL = strings.TrimSpace(job.SourceURL)
} }
jobsMu.RUnlock() jobsMu.RUnlock()
@ -591,39 +758,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
return return
} }
username := extractUsername(sourceURL) _ = refreshPreviewLiveSourceForJob(r, job)
if strings.TrimSpace(username) != "" {
jobsMu.RLock()
cookie := strings.TrimSpace(job.PreviewCookie)
ua := strings.TrimSpace(job.PreviewUA)
jobsMu.RUnlock()
if ua == "" {
ua = "Mozilla/5.0"
}
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
cancelRefresh()
if err == nil && strings.TrimSpace(newHls) != "" {
var oldHls string
jobsMu.Lock()
oldHls = strings.TrimSpace(job.PreviewM3U8)
job.PreviewM3U8 = strings.TrimSpace(newHls)
job.PreviewCookie = cookie
job.PreviewUA = ua
job.PreviewState = ""
job.PreviewStateAt = ""
job.PreviewStateMsg = ""
jobsMu.Unlock()
if oldHls != "" && oldHls != strings.TrimSpace(newHls) {
stopPreview(job)
}
}
}
// Nach möglichem Refresh alles nochmal sauber lesen // Nach möglichem Refresh alles nochmal sauber lesen
jobsMu.RLock() jobsMu.RLock()

View File

@ -7,15 +7,24 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/grafov/m3u8"
) )
// --- DVR-ähnlicher Recorder-Ablauf --- // RecordStream für Chaturbate:
// Entspricht grob dem RecordStream aus dem Channel-Snippet: // bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist.
// Stattdessen wie in der alten, funktionierenden Version:
// - Seite laden
// - HLS URL parsen
// - beste Variant-Playlist auswählen
// - Segmente selbst laden und in .ts schreiben
func RecordStream( func RecordStream(
ctx context.Context, ctx context.Context,
hc *HTTPClient, hc *HTTPClient,
@ -26,26 +35,30 @@ func RecordStream(
job *RecordJob, job *RecordJob,
) error { ) error {
username = strings.Trim(strings.TrimSpace(username), "/") username = strings.Trim(strings.TrimSpace(username), "/")
if username == "" {
return errors.New("leerer username")
}
base := strings.TrimRight(domain, "/") base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username + "/" pageURL := base + "/" + username + "/"
loadFreshHLS := func() (string, error) { loadFreshPlaylist := func() (*cbPlaylist, error) {
body, err := hc.FetchPage(ctx, pageURL, httpCookie) body, err := hc.FetchPage(ctx, pageURL, httpCookie)
if err != nil { if err != nil {
return "", fmt.Errorf("seite laden: %w", err) return nil, fmt.Errorf("seite laden: %w", err)
} }
hlsURL, err := ParseStream(body) hlsURL, err := ParseStream(body)
if err != nil { if err != nil {
return "", fmt.Errorf("stream-parsing: %w", err) return nil, fmt.Errorf("stream-parsing: %w", err)
} }
hlsURL = strings.TrimSpace(hlsURL) hlsURL = strings.TrimSpace(hlsURL)
if hlsURL == "" { if hlsURL == "" {
return "", errors.New("leere hls url") return nil, errors.New("leere hls url")
} }
finalURL, err := getWantedResolutionPlaylistWithHeaders( finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders(
ctx, ctx,
hlsURL, hlsURL,
httpCookie, httpCookie,
@ -53,13 +66,18 @@ func RecordStream(
pageURL, pageURL,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("variant-playlist: %w", err) return nil, fmt.Errorf("variant-playlist: %w", err)
} }
return strings.TrimSpace(finalURL), nil finalPlaylistURL = strings.TrimSpace(finalPlaylistURL)
if finalPlaylistURL == "" {
return nil, errors.New("leere final playlist url")
} }
hlsURL, err := loadFreshHLS() return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie)
}
playlist, err := loadFreshPlaylist()
if err != nil { if err != nil {
return err return err
} }
@ -71,7 +89,7 @@ func RecordStream(
} }
jobsMu.Lock() jobsMu.Lock()
job.PreviewM3U8 = hlsURL job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
@ -81,13 +99,62 @@ func RecordStream(
} }
} }
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
var written int64
var lastPush time.Time
var lastBytes int64
published := false
handleSegment := func(b []byte, duration float64) error {
if len(b) == 0 {
return nil
}
if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err)
}
_ = file.Sync()
if job != nil && !published {
published = true
_ = publishJob(job.ID)
}
written += int64(len(b))
if job != nil {
now := time.Now()
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
_ = publishJob(job.ID)
lastPush = now
lastBytes = written
}
}
_ = duration
return nil
}
const maxPlaylistRefreshes = 12 const maxPlaylistRefreshes = 12
refreshes := 0 refreshes := 0
for { for {
err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL) err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment)
if err == nil { if err == nil {
return nil break
} }
if ctx.Err() != nil { if ctx.Err() != nil {
@ -98,14 +165,14 @@ func RecordStream(
shouldRefresh := shouldRefresh :=
strings.Contains(msg, "403") || strings.Contains(msg, "403") ||
strings.Contains(msg, "401") || strings.Contains(msg, "401") ||
strings.Contains(msg, "invalid data found") || strings.Contains(msg, "playlist nicht mehr erreichbar") ||
strings.Contains(msg, "error opening input") || strings.Contains(msg, "fehlerhafte playlist") ||
strings.Contains(msg, "keine neuen hls-segmente") ||
strings.Contains(msg, "stream-parsing") || strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "http ") strings.Contains(msg, "http ")
if !shouldRefresh { if !shouldRefresh {
return err return fmt.Errorf("watch segments: %w", err)
} }
refreshes++ refreshes++
@ -125,7 +192,7 @@ func RecordStream(
case <-time.After(1500 * time.Millisecond): case <-time.After(1500 * time.Millisecond):
} }
newHLS, rerr := loadFreshHLS() newPlaylist, rerr := loadFreshPlaylist()
if rerr != nil { if rerr != nil {
fmt.Println("⚠️ [cb] refresh failed:", fmt.Println("⚠️ [cb] refresh failed:",
"user=", username, "user=", username,
@ -141,16 +208,25 @@ func RecordStream(
continue continue
} }
hlsURL = newHLS playlist = newPlaylist
if job != nil { if job != nil {
jobsMu.Lock() jobsMu.Lock()
job.PreviewM3U8 = hlsURL job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
} }
} }
if job != nil {
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
_ = publishJob(job.ID)
}
return nil
} }
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) // ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
@ -160,7 +236,6 @@ func ParseStream(html string) (string, error) {
return "", errors.New("room dossier nicht gefunden") return "", errors.New("room dossier nicht gefunden")
} }
// DVR-Style Unicode-Decode
decoded, err := strconv.Unquote( decoded, err := strconv.Unquote(
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
) )
@ -180,7 +255,213 @@ func ParseStream(html string) (string, error) {
return rd.HLSSource, nil return rd.HLSSource, nil
} }
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) // eigener Typ nur für Chaturbate, damit es keinen Konflikt mit main.go gibt
type cbPlaylist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
func cbFetchPlaylist(ctx context.Context, hc *HTTPClient, playlistURL string, httpCookie string) (*cbPlaylist, error) {
req, err := hc.NewRequest(ctx, http.MethodGet, playlistURL, httpCookie)
if err != nil {
return nil, fmt.Errorf("playlist request erzeugen: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
return nil, fmt.Errorf("playlist request senden: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return nil, fmt.Errorf("m3u8 parse: %w", err)
}
if listType == m3u8.MEDIA {
root := playlistURL
if idx := strings.LastIndex(root, "/"); idx >= 0 {
root = root[:idx+1]
}
return &cbPlaylist{
PlaylistURL: playlistURL,
RootURL: root,
}, nil
}
master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil {
return nil, errors.New("unerwarteter playlist-typ")
}
var bestURI string
var bestHeight int
var bestFramerate float64
for _, v := range master.Variants {
if v == nil || strings.TrimSpace(v.URI) == "" {
continue
}
h := 0
if v.Resolution != "" {
parts := strings.Split(v.Resolution, "x")
if len(parts) == 2 {
if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
h = hh
}
}
}
fr := 30.0
if v.FrameRate > 0 {
fr = v.FrameRate
}
if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) {
bestURI = strings.TrimSpace(v.URI)
bestHeight = h
bestFramerate = fr
}
}
if bestURI == "" {
return nil, errors.New("Master-Playlist ohne gültige Varianten")
}
baseURL, err := url.Parse(playlistURL)
if err != nil {
return nil, err
}
refURL, err := url.Parse(bestURI)
if err != nil {
return nil, err
}
finalURL := baseURL.ResolveReference(refURL).String()
root := finalURL
if idx := strings.LastIndex(root, "/"); idx >= 0 {
root = root[:idx+1]
}
return &cbPlaylist{
PlaylistURL: finalURL,
RootURL: root,
Resolution: bestHeight,
Framerate: int(bestFramerate),
}, nil
}
func (p *cbPlaylist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 60
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist request erzeugen: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("playlist nicht mehr erreichbar stream vermutlich offline")
}
time.Sleep(2 * time.Second)
continue
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close()
if err != nil || listType != m3u8.MEDIA {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("fehlerhafte playlist möglicherweise offline")
}
time.Sleep(2 * time.Second)
continue
}
media := playlist.(*m3u8.MediaPlaylist)
newSegment := false
for _, segment := range media.Segments {
if segment == nil {
continue
}
if int64(segment.SeqId) <= lastSeq {
continue
}
lastSeq = int64(segment.SeqId)
newSegment = true
segURL := segment.URI
if !strings.HasPrefix(segURL, "http://") && !strings.HasPrefix(segURL, "https://") {
segURL = p.RootURL + strings.TrimLeft(segment.URI, "/")
}
segReq, err := hc.NewRequest(ctx, http.MethodGet, segURL, httpCookie)
if err != nil {
continue
}
segResp, err := hc.client.Do(segReq)
if err != nil {
continue
}
if segResp.StatusCode != http.StatusOK {
segResp.Body.Close()
continue
}
data, err := io.ReadAll(segResp.Body)
segResp.Body.Close()
if err != nil || len(data) == 0 {
continue
}
if err := handler(data, segment.Duration); err != nil {
return err
}
}
if newSegment {
emptyRounds = 0
} else {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("keine neuen HLS-segmente empfangen stream vermutlich beendet oder offline")
}
}
time.Sleep(1 * time.Second)
}
}
// Cookie-Hilfsfunktion
func addCookiesFromString(req *http.Request, cookieStr string) { func addCookiesFromString(req *http.Request, cookieStr string) {
if cookieStr == "" { if cookieStr == "" {
return return
@ -203,14 +484,13 @@ func addCookiesFromString(req *http.Request, cookieStr string) {
} }
} }
// --- helper --- // helper
func extractUsername(input string) string { func extractUsername(input string) string {
s := strings.TrimSpace(input) s := strings.TrimSpace(input)
if s == "" { if s == "" {
return "" return ""
} }
// Falls volle URL
if u, err := url.Parse(s); err == nil && u != nil { if u, err := url.Parse(s); err == nil && u != nil {
host := strings.ToLower(strings.TrimSpace(u.Hostname())) host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if strings.Contains(host, "chaturbate.com") { if strings.Contains(host, "chaturbate.com") {
@ -223,7 +503,6 @@ func extractUsername(input string) string {
} }
} }
// Fallback: rohe Eingabe
s = strings.TrimPrefix(s, "https://") s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://") s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "www.") s = strings.TrimPrefix(s, "www.")
@ -239,9 +518,8 @@ func extractUsername(input string) string {
func hasChaturbateCookies(cookieStr string) bool { func hasChaturbateCookies(cookieStr string) bool {
m := parseCookieString(cookieStr) m := parseCookieString(cookieStr)
_, hasCF := m["cf_clearance"] _, hasCF := m["cf_clearance"]
// akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower)
_, hasSessID := m["session_id"] _, hasSessID := m["session_id"]
_, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt _, hasSessIdAlt := m["sessionid"]
return hasCF && (hasSessID || hasSessIdAlt) return hasCF && (hasSessID || hasSessIdAlt)
} }

View File

@ -12,6 +12,7 @@ import (
"net/url" "net/url"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -64,7 +65,10 @@ func getWantedResolutionPlaylistWithHeaders(
return playlistURL, nil return playlistURL, nil
} }
master := playlist.(*m3u8.MasterPlaylist) master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil {
return "", errors.New("unerwarteter playlist-typ: keine master-playlist")
}
var bestURI string var bestURI string
var bestHeight int var bestHeight int
@ -79,7 +83,7 @@ func getWantedResolutionPlaylistWithHeaders(
if v.Resolution != "" { if v.Resolution != "" {
parts := strings.Split(v.Resolution, "x") parts := strings.Split(v.Resolution, "x")
if len(parts) == 2 { if len(parts) == 2 {
if hh, err := strconv.Atoi(parts[1]); err == nil { if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
h = hh h = hh
} }
} }
@ -90,10 +94,10 @@ func getWantedResolutionPlaylistWithHeaders(
fr = v.FrameRate fr = v.FrameRate
} }
if h > bestHeight || (h == bestHeight && fr > bestFramerate) { if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) {
bestURI = strings.TrimSpace(v.URI)
bestHeight = h bestHeight = h
bestFramerate = fr bestFramerate = fr
bestURI = strings.TrimSpace(v.URI)
} }
} }
@ -105,12 +109,15 @@ func getWantedResolutionPlaylistWithHeaders(
if err != nil { if err != nil {
return "", err return "", err
} }
refURL, err := url.Parse(bestURI) refURL, err := url.Parse(bestURI)
if err != nil { if err != nil {
return "", err return "", err
} }
return baseURL.ResolveReference(refURL).String(), nil finalURL := baseURL.ResolveReference(refURL).String()
return finalURL, nil
} }
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
@ -153,34 +160,6 @@ func handleM3U8Mode(
return fmt.Errorf("ungültige URL: %q", m3u8URL) return fmt.Errorf("ungültige URL: %q", m3u8URL)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m3u8URL, nil)
if err != nil {
return err
}
if strings.TrimSpace(userAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
}
if strings.TrimSpace(httpCookie) != "" {
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
}
if strings.TrimSpace(referer) != "" {
req.Header.Set("Referer", strings.TrimSpace(referer))
if ru, err := url.Parse(strings.TrimSpace(referer)); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
if strings.TrimSpace(outFile) == "" { if strings.TrimSpace(outFile) == "" {
return errors.New("output file path leer") return errors.New("output file path leer")
} }
@ -214,12 +193,33 @@ func handleM3U8Mode(
args = append(args, "-headers", hdr) args = append(args, "-headers", hdr)
} }
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile)))
args = append(args, args = append(args,
"-i", m3u8URL, "-i", m3u8URL,
"-map", "0:v:0",
"-map", "0:a:0?",
"-dn",
"-ignore_unknown",
"-c", "copy", "-c", "copy",
)
switch ext {
case ".ts":
args = append(args,
"-f", "mpegts",
outFile,
)
case ".mp4":
args = append(args,
"-movflags", "+faststart", "-movflags", "+faststart",
outFile, outFile,
) )
default:
return fmt.Errorf("nicht unterstützte output-endung: %q", ext)
}
fmt.Println("🎬 ffmpeg hls args:", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
@ -246,6 +246,7 @@ func handleM3U8Mode(
if err != nil || fi == nil || fi.IsDir() { if err != nil || fi == nil || fi.IsDir() {
continue continue
} }
sz := fi.Size() sz := fi.Size()
if sz > 0 && sz != last { if sz > 0 && sz != last {
jobsMu.Lock() jobsMu.Lock()
@ -270,5 +271,14 @@ func handleM3U8Mode(
return fmt.Errorf("ffmpeg m3u8 failed: %w", err) return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
} }
if job != nil {
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJob(job.ID)
}
}
return nil return nil
} }

View File

@ -483,7 +483,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
username = "unknown" username = "unknown"
} }
filename := fmt.Sprintf("%s_%s.mp4", username, startedAt.Format("01_02_2006__15-04-05")) // ✅ Erst in TS aufnehmen
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
recordDir := strings.TrimSpace(recordDirAbs) recordDir := strings.TrimSpace(recordDirAbs)
@ -554,7 +555,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
_ = os.MkdirAll(recordDirAbs, 0o755) _ = os.MkdirAll(recordDirAbs, 0o755)
username := extractUsername(req.URL) username := extractUsername(req.URL)
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
jobsMu.Lock() jobsMu.Lock()
existingOut := strings.TrimSpace(job.Output) existingOut := strings.TrimSpace(job.Output)
@ -584,7 +585,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
_ = os.MkdirAll(recordDirAbs, 0o755) _ = os.MkdirAll(recordDirAbs, 0o755)
username := extractMFCUsername(req.URL) username := extractMFCUsername(req.URL)
filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(recordDirAbs, filename) outPath := filepath.Join(recordDirAbs, filename)
jobsMu.Lock() jobsMu.Lock()
@ -642,9 +643,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
// ------------------------------------------------------------ // ------------------------------------------------------------
// HARTE SCHRANKE 2: Output ungültig / leer vor Postwork // HARTE SCHRANKE 2: Output prüfen, aber gestoppte Downloads
// WICHTIG: bei JobStopped kurz warten, damit Windows/File-Flush // nicht aggressiv wegwerfen
// nach context canceled noch fertig werden kann
// ------------------------------------------------------------ // ------------------------------------------------------------
{ {
var ( var (
@ -653,12 +653,21 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
) )
if target == JobStopped { if target == JobStopped {
fi, serr = waitForUsableOutput(out, 2*time.Second) fi, serr = waitForUsableOutput(out, 3*time.Second)
} else { } else {
fi, serr = os.Stat(out) fi, serr = os.Stat(out)
} }
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
// Bei STOP nicht sofort wegwerfen nur wenn wirklich nichts da ist
if target == JobStopped && shouldKeepStoppedJobForInspection(target, out) {
jobsMu.Lock()
job.Phase = ""
job.Progress = 100
jobsMu.Unlock()
return
}
_ = removeWithRetry(out) _ = removeWithRetry(out)
purgeDurationCacheForPath(out) purgeDurationCacheForPath(out)
@ -808,6 +817,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}) })
} }
// 1) Erst nach done verschieben
setPhase("moving", 10) setPhase("moving", 10)
moved, err2 := moveToDoneDir(out) moved, err2 := moveToDoneDir(out)
@ -819,12 +829,27 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} else { } else {
job.Error = "moveToDoneDir returned empty path" job.Error = "moveToDoneDir returned empty path"
} }
job.Output = out
job.Phase = "" job.Phase = ""
job.Progress = 100 job.Progress = 100
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
if shouldKeepStoppedJobForInspection(postTarget, out) {
_ = publishJob(job.ID)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "moving",
Label: "Verschieben nach done fehlgeschlagen",
})
return nil
}
jobsMu.Lock() jobsMu.Lock()
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
@ -847,7 +872,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
notifyDoneChanged() notifyDoneChanged()
// 3) Datei nach move trotzdem kaputt // 2) Nach move prüfen
{ {
fi, serr := os.Stat(out) fi, serr := os.Stat(out)
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
@ -872,8 +897,108 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
} }
// 3) ✅ TS -> MP4 Remux in den Nacharbeiten
if strings.EqualFold(filepath.Ext(out), ".ts") {
setPhase("remuxing", 35)
remuxed, rerr := maybeRemuxTS(out)
if rerr != nil || strings.TrimSpace(remuxed) == "" {
jobsMu.Lock()
job.Status = JobFailed
if rerr != nil {
job.Error = "TS remux failed: " + rerr.Error()
} else {
job.Error = "TS remux returned empty path"
}
job.Output = out // originale .ts behalten
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
if shouldKeepStoppedJobForInspection(postTarget, out) {
_ = publishJob(job.ID)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "TS Remux fehlgeschlagen",
})
return nil
}
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "TS Remux fehlgeschlagen",
})
return nil
}
out = strings.TrimSpace(remuxed)
fi, serr := os.Stat(out)
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 || !strings.EqualFold(filepath.Ext(out), ".mp4") {
jobsMu.Lock()
job.Status = JobFailed
job.Error = "TS remux result invalid"
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
if shouldKeepStoppedJobForInspection(postTarget, out) {
_ = publishJob(job.ID)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "Remux-Ergebnis ungültig",
})
return nil
}
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: filepath.Base(out),
AssetID: assetIDFromVideoPath(out),
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "Remux-Ergebnis ungültig",
})
return nil
}
jobsMu.Lock()
job.Output = out
job.SizeBytes = fi.Size()
jobsMu.Unlock()
notifyDoneChanged()
}
// 4) DURATION // 4) DURATION
setPhase("probe", 35) setPhase("probe", 60)
{ {
dctx, cancel := context.WithTimeout(ctx, 6*time.Second) dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 { if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
@ -885,7 +1010,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
// 5) VIDEO PROPS // 5) VIDEO PROPS
setPhase("probe", 75) setPhase("probe", 85)
{ {
pctx, cancel := context.WithTimeout(ctx, 6*time.Second) pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
w, h, fps, perr := probeVideoProps(pctx, out) w, h, fps, perr := probeVideoProps(pctx, out)
@ -950,11 +1075,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
if postTarget == JobStopped {
_ = publishJob(job.ID)
} else {
jobsMu.Lock() jobsMu.Lock()
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
}
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile, File: finalFile,
@ -1006,8 +1135,24 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.Progress = 100 job.Progress = 100
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
job.Error = "Nachbearbeitung konnte nicht eingeplant werden"
jobsMu.Unlock() jobsMu.Unlock()
// Gestoppte Downloads mit existierender Datei nicht sofort aus der UI werfen
if shouldKeepStoppedJobForInspection(postTarget, out) {
_ = publishJob(job.ID)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "error",
Phase: "postwork",
Label: "Nachbearbeitung konnte nicht eingeplant werden",
})
return
}
jobsMu.Lock() jobsMu.Lock()
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()

View File

@ -45,7 +45,6 @@ func main() {
store := registerRoutes(mux, auth) store := registerRoutes(mux, auth)
go startChaturbateOnlinePoller(store) go startChaturbateOnlinePoller(store)
go startChaturbateBioContextPoller()
go startChaturbateAutoStartWorker(store) go startChaturbateAutoStartWorker(store)
go startMyFreeCamsAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store)
go startDiskSpaceGuard() go startDiskSpaceGuard()

View File

@ -63,6 +63,7 @@ type finishedPostworkEvent struct {
State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing" State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing"
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
Error string `json:"error,omitempty"`
Position int `json:"position,omitempty"` Position int `json:"position,omitempty"`
Waiting int `json:"waiting,omitempty"` Waiting int `json:"waiting,omitempty"`
Running int `json:"running,omitempty"` Running int `json:"running,omitempty"`

View File

@ -156,6 +156,7 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s
phase, phase,
state, state,
label, label,
"",
) )
} }

View File

@ -6,6 +6,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -60,6 +61,7 @@ func publishFinishedPostworkPhase(
phase string, phase string,
state string, state string,
label string, label string,
errMsg string,
) { ) {
setRegenerateAssetsJobPhase(file, queue, phase, state, label) setRegenerateAssetsJobPhase(file, queue, phase, state, label)
@ -71,6 +73,7 @@ func publishFinishedPostworkPhase(
State: state, State: state,
Phase: phase, Phase: phase,
Label: label, Label: label,
Error: strings.TrimSpace(errMsg),
TS: time.Now().UnixMilli(), TS: time.Now().UnixMilli(),
} }
if b, err := json.Marshal(ev); err == nil { if b, err := json.Marshal(ev); err == nil {
@ -320,18 +323,24 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen")
setRegenerateAssetsTaskError(file, "Abgebrochen") setRegenerateAssetsTaskError(file, "Abgebrochen")
publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "", "")
removeRegenerateAssetsJobLater(file, 2*time.Second) removeRegenerateAssetsJobLater(file, 2*time.Second)
} }
failJob := func(queue, phase, msg string) { failJob := func(queue, phase, msg string) {
msg = strings.TrimSpace(msg)
if msg != "" {
fmt.Printf("❌ regenerate-assets error file=%s queue=%s phase=%s error=%s\n", file, queue, phase, msg)
}
if queue != "" && phase != "" { if queue != "" && phase != "" {
publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler") publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler", msg)
} }
finishRegenerateAssetsJob(file, "error", msg) finishRegenerateAssetsJob(file, "error", msg)
@ -345,11 +354,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
} }
// Initialstatus // Initialstatus
publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
// Alte Assets löschen // Alte Assets löschen
if id != "" { if id != "" {
@ -376,12 +385,12 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta", "")
// 2) Thumb // 2) Thumb
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild", "")
if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil { if _, err := ensureThumbForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
cancelJob() cancelJob()
return return
@ -395,10 +404,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild", "")
// 3) Teaser // 3) Teaser
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser", "")
if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil { if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
@ -414,10 +423,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser", "")
// 4) Sprites // 4) Sprites
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites", "")
if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil { if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
@ -433,10 +442,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "")
// 5) KI-Analyse // 5) KI-Analyse
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "")
if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
@ -452,7 +461,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
return return
} }
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "")
finishRegenerateAssetsJob(file, "done", "") finishRegenerateAssetsJob(file, "done", "")
setRegenerateAssetsTaskDone(file, "Fertig") setRegenerateAssetsTaskDone(file, "Fertig")
@ -590,11 +599,11 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) {
enqueueRegenerateAssetsJob(job) enqueueRegenerateAssetsJob(job)
publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta") publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "")
publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "")
publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "")
publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "")
publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{

View File

@ -93,6 +93,7 @@ type JobEvent = {
modelImageUrl?: string modelImageUrl?: string
modelChatRoomUrl?: string modelChatRoomUrl?: string
ts?: number ts?: number
error?: string
postWorkKey?: string postWorkKey?: string
postWork?: { postWork?: {
@ -124,18 +125,6 @@ type TaskStateEvent = {
ts?: number ts?: number
} }
type ChaturbateBioContextResponse = {
enabled?: boolean
fetchedAt?: string
lastError?: string
model?: string
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
bio?: unknown
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records', recordDir: 'records',
doneDir: 'records/done', doneDir: 'records/done',
@ -213,39 +202,6 @@ type AutostartState = {
pausedByDisk?: boolean pausedByDisk?: boolean
} }
async function fetchChaturbateBioContextStatus(
modelKey: string,
cookiesObj: Record<string, string>
): Promise<{
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
chatRoomUrl?: string
}> {
const cookieHeader = Object.entries(cookiesObj)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
const res = await fetch(`/api/chaturbate/biocontext?model=${encodeURIComponent(modelKey)}&refresh=1`, {
method: 'GET',
cache: 'no-store',
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
})
if (!res.ok) {
return { show: 'unknown' }
}
const data = (await res.json().catch(() => null)) as ChaturbateBioContextResponse | null
return {
show: normalizePendingShow(data?.roomStatus),
imageUrl: String(data?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(data?.chatRoomUrl ?? '').trim() || undefined,
}
}
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
const s = String(v ?? '').trim().toLowerCase() const s = String(v ?? '').trim().toLowerCase()
@ -1628,18 +1584,7 @@ export default function App() {
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
if (ok && next.pendingKeyLower) { if (ok && next.pendingKeyLower) {
const kLower = next.pendingKeyLower removePendingAutoStart(next.pendingKeyLower)
setPendingAutoStartByKey((prev) => {
const copy = { ...(prev || {}) }
delete copy[kLower]
pendingAutoStartByKeyRef.current = copy
return copy
})
setPendingWatchedRooms((prev) =>
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== kLower)
)
} }
} finally { } finally {
// dedupe wieder freigeben // dedupe wieder freigeben
@ -1772,6 +1717,53 @@ export default function App() {
return () => window.removeEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any)
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) }, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => {
if (!keyLower) return
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [keyLower]: url }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: keyLower,
modelKey: keyLower,
url,
currentShow: normalizePendingShow(show),
imageUrl: imageUrl || undefined,
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
}, [])
const removePendingAutoStart = useCallback((keyLower: string) => {
if (!keyLower) return
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}) }
delete next[keyLower]
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) =>
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower)
)
}, [])
const selectedTabRef = useRef(selectedTab) const selectedTabRef = useRef(selectedTab)
const applyPendingRoomSnapshot = useCallback( const applyPendingRoomSnapshot = useCallback(
@ -1908,12 +1900,6 @@ export default function App() {
}), }),
}) })
if (!res.ok) return
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
const rooms = Array.isArray(data?.rooms) ? data!.rooms : []
const byKey: Record< const byKey: Record<
string, string,
{ {
@ -1923,6 +1909,10 @@ export default function App() {
} }
> = {} > = {}
if (res.ok) {
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
for (const room of rooms) { for (const room of rooms) {
const key = String(room?.username ?? '').trim().toLowerCase() const key = String(room?.username ?? '').trim().toLowerCase()
if (!key) continue if (!key) continue
@ -1933,10 +1923,16 @@ export default function App() {
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined, chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
} }
} }
}
// Nicht gelieferte Keys => offline
for (const key of keys) { for (const key of keys) {
const snap = byKey[key] ?? { show: 'offline' as const } const snap =
byKey[key] ??
{
show: 'unknown' as const,
imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
}
maybeNotifyWatchedModelStatusChange(key, snap.show, { maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl, imageUrl: snap.imageUrl,
@ -1944,16 +1940,24 @@ export default function App() {
applyPendingRoomSnapshot(key, snap) applyPendingRoomSnapshot(key, snap)
if (snap.show === 'public') {
const url = String((pendingMap as any)?.[key] ?? '').trim() const url = String((pendingMap as any)?.[key] ?? '').trim()
if (!url) continue if (!url) continue
if (snap.show === 'public') {
enqueueStart({ enqueueStart({
url, url,
silent: true, silent: true,
pendingKeyLower: key, pendingKeyLower: key,
}) })
continue
} }
if (snap.show === 'offline') {
removePendingAutoStart(key)
continue
}
// private / hidden / away / unknown bleiben in der Warteschlange
} }
} catch { } catch {
// ignore // ignore
@ -1976,7 +1980,13 @@ export default function App() {
cancelled = true cancelled = true
if (timer != null) window.clearTimeout(timer) if (timer != null) window.clearTimeout(timer)
} }
}, [recSettings.useChaturbateApi, applyPendingRoomSnapshot, enqueueStart]) }, [
recSettings.useChaturbateApi,
applyPendingRoomSnapshot,
enqueueStart,
maybeNotifyWatchedModelStatusChange,
removePendingAutoStart,
])
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower) const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
@ -2104,39 +2114,16 @@ export default function App() {
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) { if (mkLower) {
// silent bleibt günstig wie bisher // silent: direkt in Queue legen, Status kommt später über den Poller
if (silent) { if (silent) {
setPendingAutoStartByKey((prev) => { queuePendingAutoStart(mkLower, norm, 'unknown')
const next = { ...(prev || {}), [mkLower]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: mkLower,
modelKey: mkLower,
url: norm,
currentShow: 'unknown',
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
return true return true
} }
// manuell: erst /online let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
let resolvedImageUrl: string | undefined
let resolvedChatRoomUrl: string | undefined
try { try {
const onlineRes = await fetch('/api/chaturbate/online', { const onlineRes = await fetch('/api/chaturbate/online', {
method: 'POST', method: 'POST',
@ -2147,67 +2134,51 @@ export default function App() {
body: JSON.stringify({ q: [mkLower] }), body: JSON.stringify({ q: [mkLower] }),
}) })
let handled = false
if (onlineRes.ok) { if (onlineRes.ok) {
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
const room = Array.isArray(onlineData?.rooms) const room = Array.isArray(onlineData?.rooms)
? onlineData!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower) ? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
: null : null
if (room) { if (room) {
applyPendingRoomSnapshot(mkLower, { resolvedShow = normalizePendingShow(room.current_show)
show: normalizePendingShow(room.current_show), resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
imageUrl: String(room.image_url ?? '').trim() || undefined, resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
chatRoomUrl: String(room.chat_room_url ?? '').trim() || undefined,
})
handled = true
} }
} }
if (!handled) {
const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies)
applyPendingRoomSnapshot(mkLower, {
show: bio.show,
imageUrl: bio.imageUrl,
chatRoomUrl: bio.chatRoomUrl,
})
}
} catch { } catch {
// best effort // best effort
} }
applyPendingRoomSnapshot(mkLower, {
show: resolvedShow,
imageUrl: resolvedImageUrl,
chatRoomUrl: resolvedChatRoomUrl,
})
if (
resolvedShow === 'private' ||
resolvedShow === 'hidden' ||
resolvedShow === 'away' ||
resolvedShow === 'unknown'
) {
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl)
return true
}
if (resolvedShow === 'offline') {
removePendingAutoStart(mkLower)
setError('Model ist aktuell offline und wurde nicht zur Warteschlange hinzugefügt.')
return false
}
// nur bei public unten normal starten
} }
} catch { } catch {
if (silent) { if (silent) {
const mkLower = providerKeyLowerFromUrl(norm) const mkLower = providerKeyLowerFromUrl(norm)
if (mkLower) { if (mkLower) {
setPendingAutoStartByKey((prev) => { queuePendingAutoStart(mkLower, norm, 'unknown')
const next = { ...(prev || {}), [mkLower]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: mkLower,
modelKey: mkLower,
url: norm,
currentShow: 'unknown',
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
return true return true
} }
} }
@ -2510,6 +2481,7 @@ export default function App() {
state, state,
phase: String(msg?.phase ?? '').trim() || undefined, phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined, label: String(msg?.label ?? '').trim() || undefined,
error: String(msg?.error ?? '').trim() || undefined,
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined, position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined, waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined, running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
@ -2785,34 +2757,19 @@ export default function App() {
const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => { const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase() const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
const url = String(pending?.url ?? '').trim()
// 1) Aus pendingAutoStartByKey entfernen
if (keyLower) { if (keyLower) {
setPendingAutoStartByKey((prev) => { removePendingAutoStart(keyLower)
const next = { ...(prev || {}) } return
delete next[keyLower]
pendingAutoStartByKeyRef.current = next
return next
})
} }
// 2) Aus sichtbarer Warteliste entfernen const url = String(pending?.url ?? '').trim()
if (!url) return
setPendingWatchedRooms((prev) => setPendingWatchedRooms((prev) =>
prev.filter((x) => { prev.filter((x) => String(x?.url ?? '').trim() !== url)
const xKey = String(x?.modelKey ?? '').trim().toLowerCase()
const xUrl = String(x?.url ?? '').trim()
// bevorzugt über modelKey entfernen
if (keyLower && xKey === keyLower) return false
// Fallback über URL
if (!keyLower && url && xUrl === url) return false
return true
})
) )
}, []) }, [removePendingAutoStart])
type FinishedFileActionKind = 'delete' | 'keep' | 'rename' type FinishedFileActionKind = 'delete' | 'keep' | 'rename'

View File

@ -34,6 +34,7 @@ import {
XMarkIcon, XMarkIcon,
ArchiveBoxIcon, ArchiveBoxIcon,
TrashIcon, TrashIcon,
ClipboardDocumentIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard' import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
@ -245,6 +246,85 @@ function sleep(ms: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, ms)) return new Promise<void>((resolve) => window.setTimeout(resolve, ms))
} }
async function copyTextToClipboard(text: string): Promise<boolean> {
const value = String(text ?? '').trim()
if (!value) return false
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value)
return true
}
} catch {
// fallback below
}
try {
const ta = document.createElement('textarea')
ta.value = value
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.pointerEvents = 'none'
document.body.appendChild(ta)
ta.focus()
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
function CopyErrorButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false)
const timeoutRef = React.useRef<number | null>(null)
React.useEffect(() => {
return () => {
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current)
}
}
}, [])
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
e.stopPropagation()
const ok = await copyTextToClipboard(text || 'Unbekannter Fehler')
if (!ok) return
setCopied(true)
if (timeoutRef.current != null) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
setCopied(false)
timeoutRef.current = null
}, 1200)
}
return (
<button
type="button"
className="inline-flex items-center justify-center rounded p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
title={copied ? 'Kopiert' : 'Fehler kopieren'}
aria-label={copied ? 'Fehler kopiert' : 'Fehler kopieren'}
onClick={handleClick}
>
{copied ? (
<CheckIcon className="size-3.5 shrink-0" />
) : (
<ClipboardDocumentIcon className="size-3.5 shrink-0" />
)}
</button>
)
}
function errorTextOf(err: unknown): string { function errorTextOf(err: unknown): string {
if (err instanceof Error) return err.message || String(err) if (err instanceof Error) return err.message || String(err)
return String(err ?? '') return String(err ?? '')
@ -905,6 +985,7 @@ function summarizePostworkSteps(
phase, phase,
label: labelForStep(queue, phase), label: labelForStep(queue, phase),
state: badge.state, state: badge.state,
error: String(badge.error ?? '').trim() || undefined,
ts: badge.ts, ts: badge.ts,
position: badge.position, position: badge.position,
waiting: badge.waiting, waiting: badge.waiting,
@ -1035,7 +1116,9 @@ function mergePostworkSummaries(
step.state === 'queued' || step.state === 'queued' ||
step.state === 'error' step.state === 'error'
) { ) {
if (!prev || (step.ts || 0) >= (prev.ts || 0)) {
stepMap.set(step.key, step) stepMap.set(step.key, step)
}
continue continue
} }
@ -1227,10 +1310,18 @@ export function renderFinishedPostworkBadge(
<span className="text-emerald-700 dark:text-emerald-200">Fertig</span> <span className="text-emerald-700 dark:text-emerald-200">Fertig</span>
</> </>
) : step.state === 'error' ? ( ) : step.state === 'error' ? (
<> <div className="flex items-center gap-1.5">
<ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" /> <ExclamationTriangleIcon className="size-3.5 shrink-0 text-red-600 dark:text-red-300" />
<span className="text-red-700 dark:text-red-200">Fehler</span>
</> <span
className="text-red-700 dark:text-red-200"
title={step.error || 'Unbekannter Fehler'}
>
Fehler
</span>
{step.error ? <CopyErrorButton text={step.error} /> : null}
</div>
) : step.state === 'missing' ? ( ) : step.state === 'missing' ? (
<> <>
<span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" /> <span className="size-3.5 shrink-0 rounded-full border border-gray-300 dark:border-white/20" />
@ -2586,6 +2677,11 @@ export default function FinishedDownloads({
return true return true
} catch (e: any) { } catch (e: any) {
console.error('regenerate-assets failed', {
file: cleanFile,
error: e?.message ? String(e.message) : String(e),
})
setPostworkByFile((prev) => ({ setPostworkByFile((prev) => ({
...prev, ...prev,
[cleanFile]: [ [cleanFile]: [
@ -3188,6 +3284,7 @@ export default function FinishedDownloads({
String(detail?.label ?? '').trim() === file String(detail?.label ?? '').trim() === file
? undefined ? undefined
: detail?.label, : detail?.label,
error: String(detail?.error ?? '').trim() || undefined,
} }
const cur = Array.isArray(prev[file]) ? prev[file] : [] const cur = Array.isArray(prev[file]) ? prev[file] : []

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\HoverPopover.tsx
'use client' 'use client'
import { import {
@ -16,20 +18,25 @@ type Pos = { left: number; top: number }
type HoverPopoverAPI = { close: () => void } type HoverPopoverAPI = { close: () => void }
type HoverPopoverProps = PropsWithChildren<{ type HoverPopoverProps = PropsWithChildren<{
// Entweder direkt ein ReactNode
// oder eine Renderfunktion, die den Open-Status bekommt
// (2. Param erlaubt z.B. Close-Button im Popover)
content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode) content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode)
onOpenChange?: (open: boolean) => void
}> }>
export default function HoverPopover({ children, content }: HoverPopoverProps) { export default function HoverPopover({ children, content, onOpenChange }: HoverPopoverProps) {
const triggerRef = useRef<HTMLDivElement>(null) const triggerRef = useRef<HTMLDivElement>(null)
const popoverRef = useRef<HTMLDivElement>(null) const popoverRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [pos, setPos] = useState<Pos | null>(null) const [pos, setPos] = useState<Pos | null>(null)
// Timeout-Ref für verzögertes Schließen const setOpenState = (next: boolean) => {
setOpen((prev) => (prev === next ? prev : next))
}
useEffect(() => {
onOpenChange?.(open)
}, [open, onOpenChange])
const closeTimeoutRef = useRef<number | null>(null) const closeTimeoutRef = useRef<number | null>(null)
const clearCloseTimeout = () => { const clearCloseTimeout = () => {
@ -42,14 +49,14 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
const scheduleClose = () => { const scheduleClose = () => {
clearCloseTimeout() clearCloseTimeout()
closeTimeoutRef.current = window.setTimeout(() => { closeTimeoutRef.current = window.setTimeout(() => {
setOpen(false) setOpenState(false)
closeTimeoutRef.current = null closeTimeoutRef.current = null
}, 150) // 150ms „Gnadenzeit“ zum Rüberfahren }, 150)
} }
const handleEnter = () => { const handleEnter = () => {
clearCloseTimeout() clearCloseTimeout()
setOpen(true) setOpenState(true)
} }
const handleLeave = () => { const handleLeave = () => {
@ -58,7 +65,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
const close = () => { const close = () => {
clearCloseTimeout() clearCloseTimeout()
setOpen(false) setOpenState(false)
} }
const computePos = () => { const computePos = () => {
@ -67,25 +74,19 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
if (!trigger || !pop) return if (!trigger || !pop) return
const gap = 8 const gap = 8
const pad = 8 // Abstand zum Viewport-Rand const pad = 8
const r = trigger.getBoundingClientRect() const r = trigger.getBoundingClientRect()
const pr = pop.getBoundingClientRect() const pr = pop.getBoundingClientRect()
// Prefer: unten
let top = r.bottom + gap let top = r.bottom + gap
const bottomOverflow = top + pr.height > window.innerHeight - pad const bottomOverflow = top + pr.height > window.innerHeight - pad
if (bottomOverflow) { if (bottomOverflow) {
// Versuch: oben
const above = r.top - pr.height - gap const above = r.top - pr.height - gap
if (above >= pad) top = above if (above >= pad) top = above
else { else top = Math.max(pad, window.innerHeight - pr.height - pad)
// Notfalls clamp ins Viewport
top = Math.max(pad, window.innerHeight - pr.height - pad)
}
} }
// Left clamp
let left = r.left let left = r.left
if (left + pr.width > window.innerWidth - pad) { if (left + pr.width > window.innerWidth - pad) {
left = window.innerWidth - pr.width - pad left = window.innerWidth - pr.width - pad
@ -95,15 +96,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
setPos({ left, top }) setPos({ left, top })
} }
// Beim Öffnen: erst rendern, dann messen/positionieren
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return if (!open) return
const id = requestAnimationFrame(() => computePos()) const id = requestAnimationFrame(() => computePos())
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]) }, [open])
// Reposition bei Scroll/Resize solange offen
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const onMove = () => requestAnimationFrame(() => computePos()) const onMove = () => requestAnimationFrame(() => computePos())
@ -113,15 +111,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) {
window.removeEventListener('resize', onMove) window.removeEventListener('resize', onMove)
window.removeEventListener('scroll', onMove, true) window.removeEventListener('scroll', onMove, true)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]) }, [open])
// Cleanup für Timeout
useEffect(() => { useEffect(() => {
return () => clearCloseTimeout() return () => clearCloseTimeout()
}, []) }, [])
// Hilfsfunktion: content normalisieren
const renderContent = () => const renderContent = () =>
typeof content === 'function' typeof content === 'function'
? (content as any)(open, { close }) ? (content as any)(open, { close })

View File

@ -12,6 +12,8 @@ export default function LiveVideo({
className, className,
roomStatus, roomStatus,
onVolumeChange, onVolumeChange,
onLoadingStart,
onReady,
}: { }: {
src: string src: string
muted?: boolean muted?: boolean
@ -19,6 +21,8 @@ export default function LiveVideo({
className?: string className?: string
roomStatus?: string roomStatus?: string
onVolumeChange?: (volume: number, muted: boolean) => void onVolumeChange?: (volume: number, muted: boolean) => void
onLoadingStart?: () => void
onReady?: () => void
}) { }) {
const ref = useRef<HTMLVideoElement>(null) const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false) const [broken, setBroken] = useState(false)
@ -56,8 +60,8 @@ export default function LiveVideo({
} }
useEffect(() => { useEffect(() => {
let cancelled = false
let watchdogTimer: number | null = null let watchdogTimer: number | null = null
let readySent = false
const video = ref.current const video = ref.current
if (!video) return if (!video) return
@ -77,28 +81,58 @@ export default function LiveVideo({
} catch {} } catch {}
} }
// 1) src setzen (fMP4/MP4 stream, single HTTP response)
hardReset() hardReset()
if (!src) {
setBroken(false)
setBrokenReason(null)
return
}
onLoadingStart?.()
const markReady = () => {
if (readySent) return
readySent = true
onReady?.()
}
video.src = src video.src = src
video.load() video.load()
video.play().catch(() => {}) video.play().catch(() => {})
// 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen
let lastT = Date.now() let lastT = Date.now()
const onTime = () => { const onTime = () => {
lastT = Date.now() lastT = Date.now()
markReady()
} }
const onLoadedData = () => {
markReady()
}
const onCanPlay = () => {
markReady()
}
const onPlaying = () => {
markReady()
}
video.addEventListener('timeupdate', onTime) video.addEventListener('timeupdate', onTime)
video.addEventListener('loadeddata', onLoadedData)
video.addEventListener('canplay', onCanPlay)
video.addEventListener('playing', onPlaying)
let stalledRetries = 0 let stalledRetries = 0
watchdogTimer = window.setInterval(() => { watchdogTimer = window.setInterval(() => {
if (cancelled) return
// wenn nicht paused, aber 12s kein Fortschritt -> reconnect versuchen
if (!video.paused && Date.now() - lastT > 12_000) { if (!video.paused && Date.now() - lastT > 12_000) {
if (stalledRetries < 1) { if (stalledRetries < 1) {
stalledRetries += 1 stalledRetries += 1
readySent = false
onLoadingStart?.()
hardReset() hardReset()
video.src = src video.src = src
video.load() video.load()
@ -118,30 +152,30 @@ export default function LiveVideo({
} }
}, 4_000) }, 4_000)
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
const onError = () => { const onError = () => {
const reason = normalizeBrokenReason(roomStatus) const reason = normalizeBrokenReason(roomStatus)
if (reason === 'unknown') { if (reason === 'unknown') {
// bei unbekanntem Raumstatus nicht sofort hart fehlschlagen
return return
} }
setBroken(true) setBroken(true)
setBrokenReason(reason) setBrokenReason(reason)
} }
video.addEventListener('error', onError) video.addEventListener('error', onError)
return () => { return () => {
cancelled = true
if (watchdogTimer) window.clearInterval(watchdogTimer) if (watchdogTimer) window.clearInterval(watchdogTimer)
watchdogTimer = null watchdogTimer = null
video.removeEventListener('timeupdate', onTime) video.removeEventListener('timeupdate', onTime)
video.removeEventListener('loadeddata', onLoadedData)
video.removeEventListener('canplay', onCanPlay)
video.removeEventListener('playing', onPlaying)
video.removeEventListener('error', onError) video.removeEventListener('error', onError)
hardReset() hardReset()
} }
}, [src, roomStatus]) }, [src, roomStatus, muted, volume, onLoadingStart, onReady])
useEffect(() => { useEffect(() => {
const video = ref.current const video = ref.current

View File

@ -9,9 +9,11 @@ import {
SpeakerXMarkIcon, SpeakerXMarkIcon,
SpeakerWaveIcon, SpeakerWaveIcon,
} from '@heroicons/react/24/outline' } from '@heroicons/react/24/outline'
import LoadingSpinner from './LoadingSpinner'
type Props = { type Props = {
jobId: string jobId: string
modelKey?: string
live?: boolean live?: boolean
thumbTick?: number thumbTick?: number
autoTickMs?: number autoTickMs?: number
@ -22,6 +24,22 @@ type Props = {
fallbackSrc?: string fallbackSrc?: string
} }
function buildChaturbateCookieHeader(): string {
try {
const raw = window.localStorage.getItem('record_cookies')
if (!raw) return ''
const obj = JSON.parse(raw) as Record<string, string>
return Object.entries(obj ?? {})
.map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const)
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
} catch {
return ''
}
}
export default function ModelPreview({ export default function ModelPreview({
jobId, jobId,
live = false, live = false,
@ -38,6 +56,11 @@ export default function ModelPreview({
const [pageVisible, setPageVisible] = useState(!document.hidden) const [pageVisible, setPageVisible] = useState(!document.hidden)
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false) const [imgError, setImgError] = useState(false)
const [liveSrc, setLiveSrc] = useState('')
const [hoverOpen, setHoverOpen] = useState(false)
const [livePreparing, setLivePreparing] = useState(false)
const [liveReady, setLiveReady] = useState(false)
const [popupMuted, setPopupMuted] = useState(true) const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1) const [popupVolume, setPopupVolume] = useState(1)
@ -63,7 +86,7 @@ export default function ModelPreview({
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
}, [fallbackSrc, jobId]) }, [fallbackSrc, jobId])
const hq = useMemo( const liveStreamSrc = useMemo(
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`, () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
[jobId] [jobId]
) )
@ -74,6 +97,63 @@ export default function ModelPreview({
return () => document.removeEventListener('visibilitychange', onVis) return () => document.removeEventListener('visibilitychange', onVis)
}, []) }, [])
useEffect(() => {
const ac = new AbortController()
const loadLive = async () => {
if (!hoverOpen) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
return
}
setLivePreparing(true)
setLiveReady(false)
const cookieHeader = buildChaturbateCookieHeader()
try {
// ✅ HLS-Refresh jetzt direkt über /api/preview/live
const prepareRes = await fetch(
`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&prepare=1`,
{
method: 'GET',
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader
? { 'X-Chaturbate-Cookie': cookieHeader }
: undefined,
}
)
if (ac.signal.aborted) return
// Auch wenn prepare fehlschlägt:
// best effort -> Stream-Endpunkt trotzdem versuchen
await prepareRes.json().catch(() => null)
if (ac.signal.aborted) return
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
} catch {
if (ac.signal.aborted) return
setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`)
} finally {
if (!ac.signal.aborted) {
setLivePreparing(false)
}
}
}
void loadLive()
return () => {
ac.abort()
}
}, [hoverOpen, jobId, liveStreamSrc])
useEffect(() => { useEffect(() => {
const el = rootRef.current const el = rootRef.current
if (!el) return if (!el) return
@ -117,6 +197,14 @@ export default function ModelPreview({
return ( return (
<HoverPopover <HoverPopover
onOpenChange={(open) => {
setHoverOpen(open)
if (!open) {
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}
}}
content={(open, { close }) => content={(open, { close }) =>
open && ( open && (
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]"> <div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
@ -126,10 +214,16 @@ export default function ModelPreview({
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<LiveVideo <LiveVideo
src={hq} src={liveSrc}
muted={popupMuted} muted={popupMuted}
volume={popupVolume} volume={popupVolume}
roomStatus={roomStatus} roomStatus={roomStatus}
onLoadingStart={() => {
setLiveReady(false)
}}
onReady={() => {
setLiveReady(true)
}}
onVolumeChange={(nextVolume, nextMuted) => { onVolumeChange={(nextVolume, nextMuted) => {
setPopupVolume(nextVolume) setPopupVolume(nextVolume)
setPopupMuted(nextMuted) setPopupMuted(nextMuted)
@ -137,6 +231,15 @@ export default function ModelPreview({
className="w-full h-full object-contain object-center relative z-0" className="w-full h-full object-contain object-center relative z-0"
/> />
{livePreparing || !liveReady ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner />
<div className="text-sm font-medium">
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
</div>
</div>
) : null}
{showLiveBadge ? ( {showLiveBadge ? (
<div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm"> <div className="absolute left-2 top-2 z-[20] inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" /> <span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />

View File

@ -44,6 +44,7 @@ export type FinishedPostworkBadge = {
running?: number running?: number
maxParallel?: number maxParallel?: number
ts: number ts: number
error?: string
} }
export type FinishedPostworkStepSummary = { export type FinishedPostworkStepSummary = {
@ -57,6 +58,7 @@ export type FinishedPostworkStepSummary = {
waiting?: number waiting?: number
running?: number running?: number
maxParallel?: number maxParallel?: number
error?: string
} }
export type FinishedPostworkSummary = { export type FinishedPostworkSummary = {