This commit is contained in:
Linrador 2026-04-04 13:27:52 +02:00
parent ce92719ae7
commit 7298e4c91f
11 changed files with 1083 additions and 1007 deletions

View File

@ -23,6 +23,94 @@ type BioContextResp struct {
LastError string `json:"lastError,omitempty"` LastError string `json:"lastError,omitempty"`
Model string `json:"model"` Model string `json:"model"`
Bio any `json:"bio,omitempty"` Bio any `json:"bio,omitempty"`
// neu
RoomStatus string `json:"roomStatus,omitempty"` // public/private/hidden/away/offline/unknown
IsOnline bool `json:"isOnline,omitempty"`
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
}
func normalizeCBShow(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "public", "private", "hidden", "away", "offline":
return s
case "online", "live":
return "public"
default:
return "unknown"
}
}
func asMap(v any) map[string]any {
m, _ := v.(map[string]any)
return m
}
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
}
}
return ""
}
func inferRoomStatusFromBio(bio any) (show string, chatRoomURL string, imageURL string, ok bool) {
root := asMap(bio)
if root == nil {
return "unknown", "", "", false
}
// häufige Kandidaten
candidates := []map[string]any{
root,
asMap(root["room_status"]),
asMap(root["room"]),
asMap(root["status"]),
asMap(root["user"]),
asMap(root["broadcaster"]),
}
for _, m := range candidates {
if m == nil {
continue
}
raw := firstString(
m,
"current_show",
"room_status",
"status",
"show",
"chat_status",
)
if raw != "" {
show = normalizeCBShow(raw)
chatRoomURL = firstString(m, "chat_room_url", "room_url", "url")
imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url")
return show, chatRoomURL, imageURL, true
}
}
// Fallback: rekursiv ein paar bekannte Unterobjekte durchsuchen
for _, key := range []string{"room", "user", "broadcaster", "data"} {
if m := asMap(root[key]); m != nil {
raw := firstString(m, "current_show", "room_status", "status", "show", "chat_status")
if raw != "" {
show = normalizeCBShow(raw)
chatRoomURL = firstString(m, "chat_room_url", "room_url", "url")
imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url")
return show, chatRoomURL, imageURL, true
}
}
}
return "unknown", "", "", false
} }
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) { func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
@ -65,14 +153,21 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
// 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück // 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück
if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge { if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge {
roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(cachedBio)
isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown"
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{ _ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled, Enabled: enabled,
FetchedAt: cachedAt, FetchedAt: cachedAt,
LastError: "", LastError: "",
Model: model, Model: model,
Bio: cachedBio, Bio: cachedBio,
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
}) })
return return
} }
@ -208,18 +303,37 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
fetchedAt := time.Now().UTC() fetchedAt := time.Now().UTC()
roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(bio)
isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown"
// 5) Persistieren // 5) Persistieren
if cbModelStore != nil { if cbModelStore != nil {
_ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano)) _ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano))
if hasStatus {
_ = cbModelStore.SetChaturbateRoomState(
"chaturbate.com",
model,
roomStatus,
isOnline,
chatRoomURL,
imageURL,
fetchedAt,
)
}
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(BioContextResp{ _ = json.NewEncoder(w).Encode(BioContextResp{
Enabled: enabled, Enabled: enabled,
FetchedAt: fetchedAt, FetchedAt: fetchedAt,
LastError: "", LastError: "",
Model: model, Model: model,
Bio: bio, Bio: bio,
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
}) })
} }

View File

@ -271,6 +271,37 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
return m return m
} }
func hasActiveRecordingJobForModelKey(modelKey string) bool {
mk := strings.ToLower(strings.TrimSpace(modelKey))
if mk == "" {
return false
}
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
}
u := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL)))
if u == mk {
return true
}
}
return false
}
func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) { func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
if store == nil { if store == nil {
return return
@ -319,6 +350,11 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
continue continue
} }
// Solange ein aktiver Recorder für das Model läuft, NICHT auf offline kippen.
if hasActiveRecordingJobForModelKey(modelKey) {
continue
}
_ = store.SetChaturbateRoomState( _ = store.SetChaturbateRoomState(
"chaturbate.com", "chaturbate.com",
modelKey, modelKey,
@ -331,6 +367,167 @@ 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 {
@ -400,6 +597,10 @@ func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]Chatur
continue continue
} }
if hasActiveRecordingJobForModelKey(userLower) {
continue
}
username := strings.TrimSpace(prevRm.Username) username := strings.TrimSpace(prevRm.Username)
if username == "" { if username == "" {
username = strings.TrimSpace(userLower) username = strings.TrimSpace(userLower)
@ -690,7 +891,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us
} }
hc := NewHTTPClient(userAgent) hc := NewHTTPClient(userAgent)
pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/"
body, err := hc.FetchPage(ctx, pageURL, cookie) body, err := hc.FetchPage(ctx, pageURL, cookie)
if err != nil { if err != nil {

View File

@ -29,6 +29,13 @@ var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"
type JobStatus string type JobStatus string
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
const ( const (
JobRunning JobStatus = "running" JobRunning JobStatus = "running"
JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch
@ -57,10 +64,6 @@ type RecordJob struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
LiveThumbStarted bool `json:"-"`
// ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler) // ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler)
PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error" PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error"
PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano
@ -72,14 +75,17 @@ type RecordJob struct {
previewJPGAt time.Time `json:"-"` previewJPGAt time.Time `json:"-"`
previewGen bool `json:"-"` previewGen bool `json:"-"`
LiveThumbStarted bool `json:"-"`
PreviewDir string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
previewCancel context.CancelFunc `json:"-"`
previewLastHit time.Time `json:"-"`
previewStartMu sync.Mutex `json:"-"`
PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt
PreviewCookie string `json:"-"` // Cookie header (falls nötig) PreviewCookie string `json:"-"` // Cookie header (falls nötig)
PreviewUA string `json:"-"` // user-agent PreviewUA string `json:"-"` // user-agent
previewCancel context.CancelFunc `json:"-"`
previewLastHit time.Time `json:"-"`
previewStartMu sync.Mutex `json:"-"`
// ✅ Frontend Progress beim Stop/Finalize // ✅ Frontend Progress beim Stop/Finalize
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving Phase string `json:"phase,omitempty"` // stopping | remuxing | moving
Progress int `json:"progress,omitempty"` // 0..100 Progress int `json:"progress,omitempty"` // 0..100
@ -264,43 +270,8 @@ var (
jobsMu = sync.RWMutex{} jobsMu = sync.RWMutex{}
) )
func startPreviewIdleKiller() {
t := time.NewTicker(5 * time.Second)
go func() {
defer t.Stop()
for range t.C {
jobsMu.RLock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j != nil {
list = append(list, j)
}
}
jobsMu.RUnlock()
for _, j := range list {
jobsMu.RLock()
cmdRunning := j.previewCmd != nil
last := j.previewLastHit
st := j.Status
jobsMu.RUnlock()
if !cmdRunning {
continue
}
// wenn Job nicht mehr läuft oder Hover weg
if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) {
stopPreview(j)
}
}
}
}()
}
func init() { func init() {
initFFmpegLimiters() initFFmpegLimiters()
startPreviewIdleKiller()
initSSE() initSSE()
} }
@ -602,27 +573,18 @@ func removeGeneratedForID(id string) {
return return
} }
// falls jemand "file.mp4" übergibt
id = strings.TrimSuffix(id, filepath.Ext(id)) id = strings.TrimSuffix(id, filepath.Ext(id))
// HOT Prefix weg
id = stripHotPrefix(id) id = stripHotPrefix(id)
// wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!)
var err error var err error
id, err = sanitizeID(id) id, err = sanitizeID(id)
if err != nil || id == "" { if err != nil || id == "" {
return return
} }
// 1) NEU: generated/meta/<id>/ ...
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" { if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id)) _ = os.RemoveAll(filepath.Join(root, id))
} }
// 2) Temp Preview Segmente (HLS) wegräumen
// (%TEMP%/rec_preview/<assetID>)
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))
} }
func purgeDurationCacheForPath(p string) { func purgeDurationCacheForPath(p string) {

View File

@ -368,7 +368,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath
return false return false
} }
ext := strings.ToLower(filepath.Ext(name)) ext := strings.ToLower(filepath.Ext(name))
return ext == ".mp4" || ext == ".ts" return ext == ".mp4"
} }
for _, m := range cands { for _, m := range cands {
@ -1326,175 +1326,56 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
return b, nil return b, nil
} }
func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) { func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) {
segDir := previewSegmentsDir(previewDir) m3u8URL = strings.TrimSpace(m3u8URL)
entries, err := os.ReadDir(segDir) if m3u8URL == "" {
if err != nil { return nil, fmt.Errorf("m3u8 leer")
return nil, err
} }
if maxCount <= 0 { if strings.TrimSpace(userAgent) == "" {
maxCount = 4 userAgent = "Mozilla/5.0"
} }
names := make([]string, 0, len(entries)) args := []string{
for _, e := range entries { "-hide_banner",
if e.IsDir() { "-loglevel", "error",
continue
}
name := strings.ToLower(e.Name())
if !strings.HasPrefix(name, "seg_preview_") {
continue
}
names = append(names, e.Name())
} }
if len(names) == 0 { if strings.TrimSpace(userAgent) != "" {
return nil, fmt.Errorf("kein Preview-Segment in %s", segDir) args = append(args, "-user_agent", strings.TrimSpace(userAgent))
} }
sort.Strings(names) if strings.TrimSpace(cookie) != "" {
args = append(args, "-headers", fmt.Sprintf("Cookie: %s\r\n", strings.TrimSpace(cookie)))
if len(names) > maxCount {
names = names[len(names)-maxCount:]
} }
out := make([]string, 0, len(names)) args = append(args,
for _, name := range names { "-i", m3u8URL,
out = append(out, filepath.Join(segDir, name)) "-frames:v", "1",
"-vf", "scale=320:-2",
"-vcodec", "mjpeg",
"-q:v", "6",
"-f", "image2pipe",
"pipe:1",
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String()))
} }
return out, nil b := out.Bytes()
} if len(b) == 0 {
return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output")
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
segDir := previewSegmentsDir(previewDir)
if strings.TrimSpace(segDir) == "" {
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
} }
segPaths, err := latestPreviewSegments(previewDir, 4) return b, nil
if err != nil {
return "", nil, err
}
ext := strings.ToLower(filepath.Ext(segPaths[len(segPaths)-1]))
// Bei TS: ebenfalls lieber die letzten paar Segmente zusammenkleben,
// nicht nur das letzte einzelne Segment.
if ext == ".ts" {
tmp, err := os.CreateTemp(segDir, "preview_input_*.ts")
if err != nil {
return "", nil, err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
for _, segPath := range segPaths {
b, err := os.ReadFile(segPath)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
}
if len(b) == 0 {
continue
}
if _, err := tmp.Write(b); err != nil {
cleanup()
return "", nil, err
}
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, err
}
return tmpPath, cleanup, nil
}
// fMP4/CMAF: init.mp4 + die letzten N Segmente
initPath := filepath.Join(segDir, "init.mp4")
if _, err := os.Stat(initPath); err != nil {
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
}
initBytes, err := os.ReadFile(initPath)
if err != nil {
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
}
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
if err != nil {
return "", nil, err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
if _, err := tmp.Write(initBytes); err != nil {
cleanup()
return "", nil, err
}
for _, segPath := range segPaths {
segBytes, err := os.ReadFile(segPath)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
}
if len(segBytes) == 0 {
continue
}
if _, err := tmp.Write(segBytes); err != nil {
cleanup()
return "", nil, err
}
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, err
}
return tmpPath, cleanup, nil
}
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
defer cleanup()
img, err := extractLastFrameJPGScaled(inputPath, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(inputPath, 320, 70)
}
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
defer cleanup()
img, err := extractLastFrameJPG(inputPath)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(inputPath, 720, 75)
} }
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) { func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
@ -1796,11 +1677,21 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
var img []byte var img []byte
var genErr error var genErr error
previewDir := strings.TrimSpace(j.PreviewDir) previewM3U8 := strings.TrimSpace(j.PreviewM3U8)
if previewDir != "" { previewCookie := strings.TrimSpace(j.PreviewCookie)
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir) previewUA := strings.TrimSpace(j.PreviewUA)
if previewM3U8 != "" {
img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA)
if genErr != nil { if genErr != nil {
//fmt.Println("preview from previewDir failed:", previewDir, genErr) // optional loggen
}
}
if len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
img, _ = extractLastFrameJPGScaled(outPath, 720, 75)
} }
} }
@ -1845,7 +1736,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.RLock() jobsMu.RLock()
status := job.Status status := job.Status
previewDir := job.PreviewDir previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
previewCookie := strings.TrimSpace(job.PreviewCookie)
previewUA := strings.TrimSpace(job.PreviewUA)
out := job.Output out := job.Output
jobsMu.RUnlock() jobsMu.RUnlock()
@ -1876,19 +1769,23 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
} }
var img []byte var img []byte
if previewDir != "" {
if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 { if previewM3U8 != "" {
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
img = b img = b
} }
} }
if len(img) == 0 && out != "" { if len(img) == 0 && out != "" {
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 { if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b img = b
} }
} }
if len(img) == 0 { if len(img) == 0 {
return return
} }
_ = atomicWriteFile(thumbPath, img) _ = atomicWriteFile(thumbPath, img)
} }

View File

@ -7,38 +7,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/grafov/m3u8"
) )
func previewSegmentsDir(previewDir string) string {
previewDir = strings.TrimSpace(previewDir)
if previewDir == "" {
return ""
}
return filepath.Join(previewDir, "segments")
}
func ensurePreviewSegmentsDir(previewDir string) (string, error) {
dir := previewSegmentsDir(previewDir)
if strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("previewDir leer")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// --- DVR-ähnlicher Recorder-Ablauf --- // --- DVR-ähnlicher Recorder-Ablauf ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet: // Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream( func RecordStream(
@ -50,185 +25,132 @@ func RecordStream(
httpCookie string, httpCookie string,
job *RecordJob, job *RecordJob,
) error { ) error {
username = strings.Trim(strings.TrimSpace(username), "/")
base := strings.TrimRight(domain, "/") base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username pageURL := base + "/" + username + "/"
body, err := hc.FetchPage(ctx, pageURL, httpCookie) loadFreshHLS := func() (string, error) {
if err != nil { body, err := hc.FetchPage(ctx, pageURL, httpCookie)
return fmt.Errorf("seite laden: %w", err) if err != nil {
return "", fmt.Errorf("seite laden: %w", err)
}
hlsURL, err := ParseStream(body)
if err != nil {
return "", fmt.Errorf("stream-parsing: %w", err)
}
hlsURL = strings.TrimSpace(hlsURL)
if hlsURL == "" {
return "", errors.New("leere hls url")
}
finalURL, err := getWantedResolutionPlaylistWithHeaders(
ctx,
hlsURL,
httpCookie,
hc.userAgent,
pageURL,
)
if err != nil {
return "", fmt.Errorf("variant-playlist: %w", err)
}
return strings.TrimSpace(finalURL), nil
} }
hlsURL, err := ParseStream(body) hlsURL, err := loadFreshHLS()
if err != nil { if err != nil {
return fmt.Errorf("stream-parsing: %w", err) return err
} }
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist abrufen: %w", err)
}
var previewDir string
var segmentsDir string
if job != nil { if job != nil {
assetID := assetIDForJob(job) assetID := assetIDForJob(job)
if assetID != "" { if assetID != "" {
if dir, derr := ensureGeneratedDir(assetID); derr == nil { _, _ = ensureGeneratedDir(assetID)
previewDir = strings.TrimSpace(dir)
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
segmentsDir = strings.TrimSpace(sdir)
}
}
} }
jobsMu.Lock() jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(hlsURL) job.PreviewM3U8 = hlsURL
job.PreviewCookie = httpCookie job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
if previewDir != "" {
job.PreviewDir = previewDir
}
jobsMu.Unlock() jobsMu.Unlock()
if previewDir != "" { if assetID != "" {
startLiveThumbJPGLoop(ctx, job) startLiveThumbJPGLoop(ctx, job)
} }
} }
if segmentsDir != "" { const maxPlaylistRefreshes = 12
defer func() { refreshes := 0
if err := os.RemoveAll(segmentsDir); err != nil {
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
}
}()
}
file, err := os.Create(outputPath) for {
if err != nil { err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL)
return fmt.Errorf("datei erstellen: %w", err) if err == nil {
}
defer func() {
_ = file.Close()
}()
var written int64
var lastPush time.Time
var lastBytes int64
published := false
trimPreviewSegments := func(dir string, keep int) {
if strings.TrimSpace(dir) == "" || keep <= 0 {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type item struct {
name string
mod time.Time
}
items := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if lower == "init.mp4" {
continue
}
if !strings.HasPrefix(lower, "seg_preview_") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
items = append(items, item{
name: name,
mod: info.ModTime(),
})
}
if len(items) <= keep {
return
}
sort.Slice(items, func(i, j int) bool {
return items[i].mod.Before(items[j].mod)
})
for i := 0; i < len(items)-keep; i++ {
_ = os.Remove(filepath.Join(dir, items[i].name))
}
}
err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error {
if len(b) == 0 {
return nil return nil
} }
if _, err := file.Write(b); err != nil { if ctx.Err() != nil {
return fmt.Errorf("schreibe segment: %w", err) return ctx.Err()
} }
if err := file.Sync(); err != nil { msg := strings.ToLower(strings.TrimSpace(err.Error()))
//fmt.Println("output sync failed:", outputPath, err) shouldRefresh :=
strings.Contains(msg, "403") ||
strings.Contains(msg, "401") ||
strings.Contains(msg, "invalid data found") ||
strings.Contains(msg, "error opening input") ||
strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "http ")
if !shouldRefresh {
return err
} }
if segmentsDir != "" { refreshes++
if err := os.MkdirAll(segmentsDir, 0o755); err == nil { if refreshes > maxPlaylistRefreshes {
ext := ".m4s" return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
if len(b) > 0 && b[0] == 0x47 { }
ext = ".ts"
}
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext) fmt.Println("🔄 [cb] refresh playlist:",
segPath := filepath.Join(segmentsDir, segName) "user=", username,
"try=", refreshes,
"reason=", err,
)
if err := os.WriteFile(segPath, b, 0o644); err == nil { select {
trimPreviewSegments(segmentsDir, 12) case <-ctx.Done():
} return ctx.Err()
case <-time.After(1500 * time.Millisecond):
}
newHLS, rerr := loadFreshHLS()
if rerr != nil {
fmt.Println("⚠️ [cb] refresh failed:",
"user=", username,
"try=", refreshes,
"err=", rerr,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
} }
continue
} }
if job != nil && !published { hlsURL = newHLS
published = true
_ = publishJob(job.ID)
}
written += int64(len(b))
if job != nil { if job != nil {
now := time.Now() jobsMu.Lock()
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 { job.PreviewM3U8 = hlsURL
jobsMu.Lock() job.PreviewCookie = httpCookie
job.SizeBytes = written job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
lastPush = now
lastBytes = written
}
} }
_ = duration
_ = segmentURL
return nil
})
if err != nil {
return fmt.Errorf("watch segments: %w", err)
} }
return nil
} }
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) // ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
@ -258,289 +180,6 @@ func ParseStream(html string) (string, error) {
return rd.HLSSource, nil return rd.HLSSource, nil
} }
// --- Playlist/WatchSegments wie gehabt ---
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
type Resolution struct {
Framerate map[int]string
Width int
}
// nutzt ebenfalls *HTTPClient
func (p *Playlist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
previewDir string,
handler func(segmentURL string, b []byte, duration float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 60
playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL))
if err != nil || playlistBaseURL == nil {
return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL)
}
var lastMapURL string
writeInitIfNeeded := func(mapURL string) {
if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" {
return
}
segDir, err := ensurePreviewSegmentsDir(previewDir)
if err != nil {
return
}
initPath := filepath.Join(segDir, "init.mp4")
// schon vorhanden und gleiches URL-Mapping -> nichts tun
if mapURL == lastMapURL {
if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 {
return
}
}
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
if err != nil {
return
}
resp, err := hc.client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
_, _ = io.Copy(io.Discard, resp.Body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if len(b) == 0 {
return
}
if err := os.WriteFile(initPath, b, 0o644); err != nil {
return
}
lastMapURL = mapURL
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if strings.TrimSpace(p.PlaylistURL) == "" {
return errors.New("playlist url fehlt")
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
emptyRounds++
//fmt.Println("playlist download failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
//fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL)
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return fmt.Errorf("❌ playlist liefert status %d stream vermutlich offline", resp.StatusCode)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close()
if err != nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if listType != m3u8.MEDIA {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
media, ok := playlist.(*m3u8.MediaPlaylist)
if !ok || media == nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Media-Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if media.Map != nil {
mapURI := strings.TrimSpace(media.Map.URI)
if mapURI != "" {
mapRef, err := url.Parse(mapURI)
if err != nil {
//fmt.Println("map uri parse failed:", mapURI, err)
} else {
mapURL := playlistBaseURL.ResolveReference(mapRef).String()
writeInitIfNeeded(mapURL)
}
}
}
newSegment := false
successfulSegment := false
for _, segment := range media.Segments {
if segment == nil {
continue
}
if int64(segment.SeqId) <= lastSeq {
continue
}
lastSeq = int64(segment.SeqId)
newSegment = true
segURI := strings.TrimSpace(segment.URI)
if segURI == "" {
//fmt.Println("segment uri empty in playlist:", p.PlaylistURL)
continue
}
segRef, err := url.Parse(segURI)
if err != nil {
//fmt.Println("segment uri parse failed:", segURI, err)
continue
}
segmentURL := playlistBaseURL.ResolveReference(segRef).String()
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
if err != nil {
//fmt.Println("segment request build failed:", segmentURL, err)
continue
}
segResp, err := hc.client.Do(segReq)
if err != nil {
//fmt.Println("segment download failed:", segmentURL, err)
continue
}
if segResp.StatusCode < 200 || segResp.StatusCode >= 300 {
//fmt.Println("segment bad status:", segResp.StatusCode, segmentURL)
_, _ = io.Copy(io.Discard, segResp.Body)
segResp.Body.Close()
continue
}
data, err := io.ReadAll(segResp.Body)
segResp.Body.Close()
if err != nil {
//fmt.Println("segment read failed:", segmentURL, err)
continue
}
if len(data) == 0 {
//fmt.Println("segment empty:", segmentURL)
continue
}
if err := handler(segmentURL, data, segment.Duration); err != nil {
return err
}
successfulSegment = true
}
if successfulSegment {
emptyRounds = 0
} else if newSegment {
emptyRounds++
//fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds {
return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden Stream vermutlich geschützt, fehlerhaft oder offline.")
}
} else {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("🛑 Keine neuen HLS-Segmente empfangen Stream vermutlich beendet oder offline.")
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) // Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
func addCookiesFromString(req *http.Request, cookieStr string) { func addCookiesFromString(req *http.Request, cookieStr string) {
if cookieStr == "" { if cookieStr == "" {

View File

@ -0,0 +1,274 @@
// backend\record_stream_hls.go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/grafov/m3u8"
)
func getWantedResolutionPlaylistWithHeaders(
ctx context.Context,
playlistURL string,
httpCookie string,
userAgent string,
referer string,
) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, 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) != "" {
ref := strings.TrimSpace(referer)
req.Header.Set("Referer", ref)
if ru, err := url.Parse(ref); 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
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return "", fmt.Errorf("m3u8 parse: %w", err)
}
if listType == m3u8.MEDIA {
return playlistURL, nil
}
master := playlist.(*m3u8.MasterPlaylist)
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(parts[1]); err == nil {
h = hh
}
}
}
fr := 30.0
if v.FrameRate > 0 {
fr = v.FrameRate
}
if h > bestHeight || (h == bestHeight && fr > bestFramerate) {
bestHeight = h
bestFramerate = fr
bestURI = strings.TrimSpace(v.URI)
}
}
if bestURI == "" {
return "", errors.New("Master-Playlist ohne gültige Varianten")
}
baseURL, err := url.Parse(playlistURL)
if err != nil {
return "", err
}
refURL, err := url.Parse(bestURI)
if err != nil {
return "", err
}
return baseURL.ResolveReference(refURL).String(), nil
}
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
lines := make([]string, 0, 6)
if strings.TrimSpace(httpCookie) != "" {
lines = append(lines, fmt.Sprintf("Cookie: %s", strings.TrimSpace(httpCookie)))
}
if strings.TrimSpace(referer) != "" {
ref := strings.TrimSpace(referer)
lines = append(lines, fmt.Sprintf("Referer: %s", ref))
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
lines = append(lines, fmt.Sprintf("Origin: %s://%s", ru.Scheme, ru.Host))
}
}
if strings.TrimSpace(userAgent) != "" {
lines = append(lines, fmt.Sprintf("User-Agent: %s", strings.TrimSpace(userAgent)))
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\r\n") + "\r\n"
}
func handleM3U8Mode(
ctx context.Context,
m3u8URL string,
outFile string,
job *RecordJob,
httpCookie string,
userAgent string,
referer string,
) error {
u, err := url.Parse(m3u8URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
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")
}
args := []string{
"-y",
"-hide_banner",
"-nostats",
"-loglevel", "warning",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
"-http_persistent", "false",
}
ua := strings.TrimSpace(userAgent)
ref := strings.TrimSpace(referer)
ck := strings.TrimSpace(httpCookie)
if ua != "" {
args = append(args, "-user_agent", ua)
}
if ref != "" {
args = append(args, "-referer", ref)
}
if ck != "" {
args = append(args, "-cookies", ck)
}
if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" {
args = append(args, "-headers", hdr)
}
args = append(args,
"-i", m3u8URL,
"-c", "copy",
"-movflags", "+faststart",
outFile,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
stopStat := make(chan struct{})
if job != nil {
go func() {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
var last int64
for {
select {
case <-ctx.Done():
return
case <-stopStat:
return
case <-t.C:
fi, err := os.Stat(outFile)
if err != nil || fi == nil || fi.IsDir() {
continue
}
sz := fi.Size()
if sz > 0 && sz != last {
jobsMu.Lock()
job.SizeBytes = sz
jobsMu.Unlock()
last = sz
_ = publishJob(job.ID)
}
}
}
}()
}
err = cmd.Run()
close(stopStat)
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
}
return nil
}

View File

@ -10,8 +10,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"os/exec"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -89,7 +87,7 @@ func RecordStreamMFC(
} }
// Aufnahme starten // Aufnahme starten
return handleM3U8Mode(ctx, m3u8URL, outputPath, job) return handleM3U8Mode(ctx, m3u8URL, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL())
} }
type MyFreeCams struct { type MyFreeCams struct {
@ -245,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return errors.New("keine m3u8 URL gefunden") return errors.New("keine m3u8 URL gefunden")
} }
return handleM3U8Mode(ctx, m3u8URL, outArg, nil) return handleM3U8Mode(ctx, m3u8URL, outArg, nil, "", "", mfc.GetWebsiteURL())
} }
func getWantedResolutionPlaylist(playlistURL string) (string, error) { func getWantedResolutionPlaylist(playlistURL string) (string, error) {
@ -310,98 +308,6 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) {
return root + bestURI, nil return root + bestURI, nil
} }
func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob) error {
// Validierung
u, err := url.Parse(m3u8URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige URL: %q", m3u8URL)
}
// HTTP-Check MIT Context
req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
if strings.TrimSpace(outFile) == "" {
return errors.New("output file path leer")
}
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-y",
"-hide_banner",
"-nostats",
"-loglevel", "warning",
"-i", m3u8URL,
"-c", "copy",
outFile,
)
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
// ✅ live size polling während ffmpeg läuft
stopStat := make(chan struct{})
if job != nil {
go func() {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
var last int64
for {
select {
case <-ctx.Done():
return
case <-stopStat:
return
case <-t.C:
fi, err := os.Stat(outFile)
if err != nil {
continue
}
sz := fi.Size()
if sz > 0 && sz != last {
jobsMu.Lock()
job.SizeBytes = sz
jobsMu.Unlock()
last = sz
}
}
}
}()
}
// ✅ WICHTIG: ffmpeg wirklich laufen lassen
err = cmd.Run()
close(stopStat)
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
}
return nil
}
/* /*
Kleine Helper für MFC Kleine Helper für MFC
*/ */

View File

@ -262,6 +262,127 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
// ---------------- Start + run job ---------------- // ---------------- Start + run job ----------------
func markModelPublicWhileRecording(job *RecordJob) {
if job == nil {
return
}
rawURL := strings.TrimSpace(job.SourceURL)
if rawURL == "" {
return
}
if detectProvider(rawURL) != "chaturbate" {
return
}
modelKey := strings.TrimSpace(extractUsername(rawURL))
if modelKey == "" {
return
}
store := getModelStore()
if store == nil {
return
}
now := time.Now().UTC()
_ = store.SetChaturbateRoomState(
"chaturbate.com",
modelKey,
"public",
true,
rawURL,
"",
now,
)
}
func safeFileSize(path string) int64 {
path = strings.TrimSpace(path)
if path == "" {
return 0
}
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() {
return 0
}
return fi.Size()
}
func statOutputForLog(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return "path-empty"
}
fi, err := os.Stat(path)
if err != nil {
return "stat-error=" + err.Error()
}
if fi == nil {
return "stat-nil"
}
if fi.IsDir() {
return "is-dir"
}
return fmt.Sprintf("exists size=%d mod=%s", fi.Size(), fi.ModTime().Format(time.RFC3339Nano))
}
func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, fmt.Errorf("path empty")
}
deadline := time.Now().Add(timeout)
var lastErr error
for {
fi, err := os.Stat(path)
if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return fi, nil
}
if err != nil {
lastErr = err
} else if fi == nil {
lastErr = fmt.Errorf("stat returned nil")
} else if fi.IsDir() {
lastErr = fmt.Errorf("path is dir")
} else {
lastErr = fmt.Errorf("file size is 0")
}
if time.Now().After(deadline) {
break
}
time.Sleep(150 * time.Millisecond)
}
if lastErr == nil {
lastErr = fmt.Errorf("usable output not ready in time")
}
return nil, lastErr
}
func shouldKeepStoppedJobForInspection(target JobStatus, out string) bool {
if target != JobStopped {
return false
}
fi, err := os.Stat(strings.TrimSpace(out))
if err != nil || fi == nil || fi.IsDir() {
return false
}
// auch kleine Fragmente sichtbar behalten, damit du sie debuggen kannst
return fi.Size() > 0
}
func isActiveRecordingJob(job *RecordJob) bool { func isActiveRecordingJob(job *RecordJob) bool {
if job == nil { if job == nil {
return false return false
@ -310,6 +431,11 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
return nil, errors.New("url fehlt") return nil, errors.New("url fehlt")
} }
provider := detectProvider(url)
if provider != "chaturbate" && provider != "mfc" {
return nil, fmt.Errorf("unsupported provider: %q", url)
}
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock() defer jobsMu.Unlock()
@ -345,7 +471,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
} }
startedAt := time.Now() startedAt := time.Now()
provider := detectProvider(url)
username := "" username := ""
switch provider { switch provider {
@ -358,7 +483,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
username = "unknown" username = "unknown"
} }
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.mp4", 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)
@ -382,8 +507,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
} }
jobs[jobID] = job jobs[jobID] = job
go runJob(ctx, job, req) go runJob(ctx, job, req)
return job, nil return job, nil
} }
@ -429,7 +554,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.ts", username, now.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
jobsMu.Lock() jobsMu.Lock()
existingOut := strings.TrimSpace(job.Output) existingOut := strings.TrimSpace(job.Output)
@ -446,6 +571,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
} }
markModelPublicWhileRecording(job)
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
case "mfc": case "mfc":
@ -458,7 +584,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.ts", username, now.Format("01_02_2006__15-04-05")) filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(recordDirAbs, filename) outPath := filepath.Join(recordDirAbs, filename)
jobsMu.Lock() jobsMu.Lock()
@ -489,6 +615,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.EndedAt = &end job.EndedAt = &end
job.EndedAtMs = end.UnixMilli() job.EndedAtMs = end.UnixMilli()
job.Status = target
if errText != "" { if errText != "" {
job.Error = errText job.Error = errText
} }
@ -496,22 +623,41 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
jobsMu.Unlock() jobsMu.Unlock()
// ------------------------------------------------------------
// HARTE SCHRANKE 1: gar kein Output-Pfad
// ------------------------------------------------------------
if out == "" { if out == "" {
jobsMu.Lock() jobsMu.Lock()
job.Status = target
job.Phase = "" job.Phase = ""
job.Progress = 100 job.Progress = 100
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
return return
} }
// harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken // ------------------------------------------------------------
// 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
// ------------------------------------------------------------
{ {
fi, serr := os.Stat(out) var (
fi os.FileInfo
serr error
)
if target == JobStopped {
fi, serr = waitForUsableOutput(out, 2*time.Second)
} else {
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 {
_ = removeWithRetry(out) _ = removeWithRetry(out)
purgeDurationCacheForPath(out) purgeDurationCacheForPath(out)
@ -521,21 +667,13 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
if shouldLogRecordInfo(req) {
if serr != nil {
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(stat error:", serr, ")")
} else if fi == nil || fi.IsDir() {
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(not a regular file)")
} else {
fmt.Println("🧹 removed empty output before postwork:", filepath.Base(out), "(0 bytes)")
}
}
return return
} }
} }
// pre-queue gate: nur in die Nachbearbeitung, wenn Datei behalten werden soll // ------------------------------------------------------------
// AUTO DELETE SMALL DOWNLOADS
// ------------------------------------------------------------
{ {
fi, serr := os.Stat(out) fi, serr := os.Stat(out)
if serr == nil && fi != nil && !fi.IsDir() { if serr == nil && fi != nil && !fi.IsDir() {
@ -563,10 +701,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
if shouldLogRecordInfo(req) {
fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")")
}
return return
} }
@ -576,7 +710,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
} }
// postwork queue // ------------------------------------------------------------
// POSTWORK QUEUE
// ------------------------------------------------------------
postOut := out postOut := out
postTarget := target postTarget := target
postKey := "postwork:" + job.ID postKey := "postwork:" + job.ID
@ -619,24 +755,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
out := strings.TrimSpace(postOut) out := strings.TrimSpace(postOut)
if out == "" { if out == "" {
jobsMu.Lock() jobsMu.Lock()
job.Status = postTarget
job.Phase = "" job.Phase = ""
job.Progress = 100 job.Progress = 100
job.Status = postTarget
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: postFile,
AssetID: postAssetID,
Queue: "postwork",
State: "done",
Label: "Primäre Nachbearbeitung fertig",
})
return nil return nil
} }
@ -676,52 +808,46 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
}) })
} }
// 1) Remux
// 1) Remux
if strings.EqualFold(filepath.Ext(out), ".ts") {
setPhase("remuxing", 10)
newOut, err2 := maybeRemuxTSForJob(job, out)
if err2 != nil || strings.TrimSpace(newOut) == "" {
finalFile := filepath.Base(out)
finalAssetID := assetIDFromVideoPath(out)
jobsMu.Lock()
job.Status = JobFailed
job.Error = "TS->MP4 Remux fehlgeschlagen"
if err2 != nil {
job.Error = "TS->MP4 Remux fehlgeschlagen: " + err2.Error()
}
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
File: finalFile,
AssetID: finalAssetID,
Queue: "postwork",
State: "error",
Phase: "remuxing",
Label: "Remux fehlgeschlagen",
})
return nil
}
out = strings.TrimSpace(newOut)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
}
// 2) Move to done
setPhase("moving", 10) setPhase("moving", 10)
// auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben moved, err2 := moveToDoneDir(out)
if err2 != nil || strings.TrimSpace(moved) == "" {
jobsMu.Lock()
job.Status = JobFailed
if err2 != nil {
job.Error = "moveToDoneDir failed: " + err2.Error()
} else {
job.Error = "moveToDoneDir returned empty path"
}
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
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: "moving",
Label: "Verschieben nach done fehlgeschlagen",
})
return nil
}
out = strings.TrimSpace(moved)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
notifyDoneChanged()
// 3) Datei nach move trotzdem kaputt
{ {
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 {
@ -742,29 +868,11 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
Phase: "moving", Phase: "moving",
Label: "Nachbearbeitung fehlgeschlagen", Label: "Nachbearbeitung fehlgeschlagen",
}) })
if shouldLogRecordInfo(req) {
if serr != nil {
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")")
} else if fi == nil || fi.IsDir() {
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(not a regular file)")
} else {
fmt.Println("🧹 removed empty post-remux output:", filepath.Base(out), "(0 bytes)")
}
}
return nil return nil
} }
} }
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" { // 4) DURATION
out = strings.TrimSpace(moved)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
notifyDoneChanged()
}
// 3) Duration
setPhase("probe", 35) setPhase("probe", 35)
{ {
dctx, cancel := context.WithTimeout(ctx, 6*time.Second) dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
@ -776,7 +884,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
cancel() cancel()
} }
// 4) Video props // 5) VIDEO PROPS
setPhase("probe", 75) setPhase("probe", 75)
{ {
pctx, cancel := context.WithTimeout(ctx, 6*time.Second) pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
@ -791,7 +899,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
} }
// 5) Nur primäre Assets synchron: preview.jpg + preview.mp4 // 6) PRIMARY ASSETS
setPhase("assets", 0) setPhase("assets", 0)
lastPct := -1 lastPct := -1
@ -830,7 +938,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
setPhase("assets", 100) setPhase("assets", 100)
// Finalize primary postwork // FINALIZE PRIMARY POSTWORK
finalFile := filepath.Base(out) finalFile := filepath.Base(out)
finalAssetID := assetIDFromVideoPath(out) finalAssetID := assetIDFromVideoPath(out)
@ -842,6 +950,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{
@ -896,6 +1008,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
publishJobRemove(job) publishJobRemove(job)
publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{

View File

@ -9,7 +9,7 @@
"maxConcurrentDownloads": 80, "maxConcurrentDownloads": 80,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true, "autoDeleteSmallDownloads": false,
"autoDeleteSmallDownloadsBelowMB": 100, "autoDeleteSmallDownloadsBelowMB": 100,
"lowDiskPauseBelowGB": 5, "lowDiskPauseBelowGB": 5,
"blurPreviews": false, "blurPreviews": false,

View File

@ -45,6 +45,7 @@ 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

@ -124,6 +124,18 @@ 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',
@ -201,6 +213,39 @@ 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()
@ -1939,64 +1984,6 @@ export default function App() {
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
}, [chaturbateStoreKeysLower]) }, [chaturbateStoreKeysLower])
const fetchChaturbateCurrentShow = useCallback(async (modelKey: string) => {
const keyLower = String(modelKey ?? '').trim().toLowerCase()
if (!keyLower) {
return {
show: 'unknown' as const,
imageUrl: '',
chatRoomUrl: '',
}
}
try {
const res = await fetch('/api/chaturbate/online', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: [keyLower],
}),
})
if (!res.ok) {
return {
show: 'unknown' as const,
imageUrl: '',
chatRoomUrl: '',
}
}
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
const room = Array.isArray(data?.rooms)
? data!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === keyLower)
: null
if (!room) {
return {
show: 'offline' as const,
imageUrl: '',
chatRoomUrl: '',
}
}
return {
show: normalizePendingShow(room.current_show),
imageUrl: String(room.image_url ?? '').trim(),
chatRoomUrl: String(room.chat_room_url ?? '').trim(),
}
} catch {
return {
show: 'unknown' as const,
imageUrl: '',
chatRoomUrl: '',
}
}
}, [])
useEffect(() => { useEffect(() => {
selectedTabRef.current = selectedTab selectedTabRef.current = selectedTab
}, [selectedTab]) }, [selectedTab])
@ -2070,17 +2057,14 @@ export default function App() {
} }
}, [authed, recSettings.useChaturbateApi]) }, [authed, recSettings.useChaturbateApi])
function shouldQueueForRoomStatus( function shouldAlwaysQueueForRoomStatus(show: string): boolean {
show: string
): boolean {
const s = String(show || '').trim().toLowerCase() const s = String(show || '').trim().toLowerCase()
return ( return s === 'private' || s === 'hidden' || s === 'away'
s === 'private' || }
s === 'hidden' ||
s === 'away' || function shouldQueueForOfflineLikeStatus(show: string): boolean {
s === 'offline' || const s = String(show || '').trim().toLowerCase()
s === 'unknown' return s === 'offline' || s === 'unknown'
)
} }
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
@ -2118,7 +2102,7 @@ export default function App() {
}) })
if (alreadyRunning) return true if (alreadyRunning) return true
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status // ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
try { try {
const parsed = await apiJSON<ParsedModel>('/api/models/parse', { const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
@ -2130,26 +2114,20 @@ export default function App() {
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) { if (mkLower) {
const upsertPendingRow = (opts?: { // silent bleibt günstig wie bisher
show?: unknown if (silent) {
imageUrl?: string setPendingAutoStartByKey((prev) => {
chatRoomUrl?: string const next = { ...(prev || {}), [mkLower]: norm }
}) => { pendingAutoStartByKeyRef.current = next
const model = modelsByKeyRef.current[mkLower] as any return next
const show = normalizePendingShow(opts?.show ?? model?.roomStatus) })
const imageUrl =
String(opts?.imageUrl ?? '').trim() ||
String(model?.imageUrl ?? '').trim() ||
undefined
setPendingWatchedRooms((prev) => { setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = { const nextItem: PendingWatchedRoom = {
id: mkLower, id: mkLower,
modelKey: mkLower, modelKey: mkLower,
url: norm, url: norm,
currentShow: show, currentShow: 'unknown',
imageUrl,
} }
const idx = prev.findIndex( const idx = prev.findIndex(
@ -2164,64 +2142,54 @@ export default function App() {
return [nextItem, ...prev] return [nextItem, ...prev]
}) })
}
const enqueuePending = (opts?: {
show?: unknown
imageUrl?: string
chatRoomUrl?: string
}) => {
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [mkLower]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
upsertPendingRow(opts)
applyPendingRoomSnapshot(mkLower, {
show: normalizePendingShow(opts?.show),
imageUrl: String(opts?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(opts?.chatRoomUrl ?? '').trim() || undefined,
})
}
// Wenn gerade andere Starts laufen -> direkt in Warteschlange
if (busyRef.current) {
enqueuePending({ show: 'unknown' })
return true return true
} }
// Live current_show prüfen // manuell: erst /online
const live = await fetchChaturbateCurrentShow(mkLower) try {
const liveShow = normalizePendingShow(live?.show) const onlineRes = await fetch('/api/chaturbate/online', {
method: 'POST',
if (shouldQueueForRoomStatus(liveShow)) { cache: 'no-store',
enqueuePending({ headers: {
show: liveShow, 'Content-Type': 'application/json',
imageUrl: live?.imageUrl, },
chatRoomUrl: live?.chatRoomUrl, body: JSON.stringify({ q: [mkLower] }),
}) })
return true
}
// public -> Snapshot aktualisieren und normal starten let handled = false
applyPendingRoomSnapshot(mkLower, {
show: liveShow, if (onlineRes.ok) {
imageUrl: live?.imageUrl, const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
chatRoomUrl: live?.chatRoomUrl, const room = Array.isArray(onlineData?.rooms)
}) ? 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
}
}
if (!handled) {
const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies)
applyPendingRoomSnapshot(mkLower, {
show: bio.show,
imageUrl: bio.imageUrl,
chatRoomUrl: bio.chatRoomUrl,
})
}
} catch {
// best effort
}
} }
} catch { } catch {
// Wenn Live-Check fehlschlägt: lieber in Warteschlange statt blind starten if (silent) {
try { const mkLower = providerKeyLowerFromUrl(norm)
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: norm }),
})
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) { if (mkLower) {
setPendingAutoStartByKey((prev) => { setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [mkLower]: norm } const next = { ...(prev || {}), [mkLower]: norm }
@ -2252,8 +2220,6 @@ export default function App() {
return true return true
} }
} catch {
// parse fail -> normal starten
} }
} }
} }
@ -2298,7 +2264,7 @@ export default function App() {
setBusy(false) setBusy(false)
busyRef.current = false busyRef.current = false
} }
}, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow]) }, [])
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
useEffect(() => { useEffect(() => {