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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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