diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 9c502e6..d9f317c 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -20,6 +20,26 @@ func normUser(s string) string { return strings.ToLower(strings.TrimSpace(s)) } +func watchedChaturbateUserKey(m WatchedModelLite) string { + key := normUser(m.ModelKey) + if key != "" { + return key + } + + in := strings.TrimSpace(m.Input) + if in == "" { + return "" + } + + // zuerst echte URL parsen + if u := chaturbateUserFromURL(in); u != "" { + return u + } + + // fallback: roher input könnte schon nur der username sein + return normUser(strings.Trim(in, "/")) +} + func chaturbateUserFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { @@ -83,10 +103,15 @@ func resolveChaturbateURL(m WatchedModelLite) string { if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") { return in } + key := strings.Trim(strings.TrimSpace(m.ModelKey), "/") + if key == "" { + key = watchedChaturbateUserKey(m) + } if key == "" { return "" } + return fmt.Sprintf("https://chaturbate.com/%s/", key) } @@ -189,8 +214,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) { return } - const pollInterval = 5 * time.Second - const startGap = 1500 * time.Millisecond + const scanInterval = 1 * time.Second + const startInterval = 5 * time.Second // normaler Retry für API-public const retryCooldown = 25 * time.Second @@ -206,208 +231,279 @@ func startChaturbateAutoStartWorker(store *ModelStore) { lastTry := map[string]time.Time{} lastBlindTry := map[string]time.Time{} - var lastStart time.Time + scanTicker := time.NewTicker(scanInterval) + startTicker := time.NewTicker(startInterval) + defer scanTicker.Stop() + defer startTicker.Stop() + + var lastCookieHdr string for { - if isAutostartPaused() { - time.Sleep(2 * time.Second) - continue - } - - s := getSettings() - if !s.UseChaturbateAPI { - queue = queue[:0] - queued = map[string]bool{} - time.Sleep(2 * time.Second) - continue - } - - cookieHdr := cookieHeaderFromSettings(s) - if !hasChaturbateCookies(cookieHdr) { - time.Sleep(5 * time.Second) - continue - } - - // online snapshot aus cache - cbMu.RLock() - rooms := append([]ChaturbateRoom(nil), cb.Rooms...) - cbMu.RUnlock() - - showByUser := map[string]string{} - seenInAPI := map[string]bool{} - - for _, r := range rooms { - u := normUser(r.Username) - if u == "" { + select { + case <-scanTicker.C: + if isAutostartPaused() { continue } - seenInAPI[u] = true - showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) - } - // laufende Jobs sammeln - running := map[string]bool{} - jobsMu.RLock() - for _, j := range jobs { - if !isActiveRecordingJob(j) { + s := getSettings() + if !s.UseChaturbateAPI { + queue = queue[:0] + queued = map[string]bool{} + lastCookieHdr = "" continue } - u := chaturbateUserFromURL(j.SourceURL) - if u != "" { - running[u] = true - } - } - jobsMu.RUnlock() - // watched list aus DB - watched := store.ListWatchedLite("chaturbate.com") - watchedByUser := map[string]WatchedModelLite{} - for _, m := range watched { - key := normUser(m.ModelKey) - if key != "" && m.Watching { + cookieHdr := cookieHeaderFromSettings(s) + if !hasChaturbateCookies(cookieHdr) { + lastCookieHdr = "" + continue + } + lastCookieHdr = cookieHdr + + // online snapshot aus cache + cbMu.RLock() + rooms := append([]ChaturbateRoom(nil), cb.Rooms...) + cbMu.RUnlock() + + showByUser := map[string]string{} + seenInAPI := map[string]bool{} + + for _, r := range rooms { + u := normUser(r.Username) + if u == "" { + continue + } + seenInAPI[u] = true + showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) + } + + // laufende Jobs sammeln + running := map[string]bool{} + jobsMu.RLock() + for _, j := range jobs { + if !isActiveRecordingJob(j) { + continue + } + u := chaturbateUserFromURL(j.SourceURL) + if u != "" { + running[u] = true + } + } + jobsMu.RUnlock() + + // watched list aus DB + // bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben + watched := store.ListWatchedLite("") + watchedByUser := map[string]WatchedModelLite{} + for _, m := range watched { + if !m.Watching { + continue + } + + // nur Chaturbate-Einträge berücksichtigen + host := strings.ToLower(strings.TrimSpace(m.Host)) + host = strings.TrimPrefix(host, "www.") + + if host != "" && host != "chaturbate.com" { + // Falls Host gesetzt und nicht chaturbate -> ignorieren + continue + } + + // Falls Host leer ist, versuchen wir über Input/ModelKey zu erkennen + key := watchedChaturbateUserKey(m) + if key == "" { + continue + } + + // Wenn Host leer ist, aber Input eindeutig nicht Chaturbate ist -> skip + if host == "" { + in := strings.TrimSpace(m.Input) + if in != "" { + lowerIn := strings.ToLower(in) + if strings.Contains(lowerIn, "http://") || strings.Contains(lowerIn, "https://") { + if chaturbateUserFromURL(in) == "" { + continue + } + } + } + } + watchedByUser[key] = m } - } - // queue prune - nextQueue := queue[:0] - nextQueued := map[string]bool{} - for _, it := range queue { - m, ok := watchedByUser[it.userKey] - if !ok { - continue - } - if running[it.userKey] { - continue - } - - it.url = resolveChaturbateURL(m) - if it.url == "" { - continue - } - - nextQueue = append(nextQueue, it) - nextQueued[it.userKey] = true - } - queue = nextQueue - queued = nextQueued - - now := time.Now() - - // ------------------------------------------------------------ - // 1) watched + API sagt public => normal enqueue - // ------------------------------------------------------------ - for user, m := range watchedByUser { - if showByUser[user] != "public" { - continue - } - if running[user] { - continue - } - if queued[user] { - continue - } - if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown { - continue - } - - u := resolveChaturbateURL(m) - if u == "" { - continue - } - - queue = append(queue, autoStartItem{ - userKey: user, - url: u, - }) - queued[user] = true - } - - // ------------------------------------------------------------ - // 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) + // queue prune + nextQueue := queue[:0] + nextQueued := map[string]bool{} + for _, it := range queue { + m, ok := watchedByUser[it.userKey] + if !ok { + continue } + if running[it.userKey] { + continue + } + + it.url = resolveChaturbateURL(m) + if it.url == "" { + continue + } + + nextQueue = append(nextQueue, it) + nextQueued[it.userKey] = true + } + queue = nextQueue + queued = nextQueued + + now := time.Now() + + // ------------------------------------------------------------ + // 1) watched + API sagt public => normal enqueue + // ------------------------------------------------------------ + for user, m := range watchedByUser { + show := strings.ToLower(strings.TrimSpace(showByUser[user])) + if show == "" { + continue + } + if !strings.Contains(show, "public") { + continue + } + if running[user] { + continue + } + if queued[user] { + continue + } + if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown { + continue + } + + u := resolveChaturbateURL(m) + if u == "" { + continue + } + + queue = append(queue, autoStartItem{ + userKey: user, + url: u, + }) + queued[user] = true + } + + // ------------------------------------------------------------ + // 2) watched + NICHT in API => blind best-effort try vormerken + // ------------------------------------------------------------ + 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 + } + + queue = append(queue, autoStartItem{ + userKey: user, + url: u, + }) + queued[user] = true + } + + case <-startTicker.C: + if isAutostartPaused() { continue } - if verboseLogs() { - fmt.Println("▶️ [autostart] blind try started:", u) + s := getSettings() + if !s.UseChaturbateAPI { + continue } - go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) - lastStart = time.Now() - } + if !hasChaturbateCookies(lastCookieHdr) { + continue + } + + if len(queue) == 0 { + continue + } - // ------------------------------------------------------------ - // 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() - job, err := startRecordingInternal(RecordRequest{ - URL: it.url, - Cookie: cookieHdr, - }) - if err != nil { - if verboseLogs() { - fmt.Println("❌ [autostart] start failed:", it.url, err) + // prüfen, ob inzwischen schon aktiv + if isJobRunningForURL(it.url) { + continue + } + + // unterscheiden: API-public oder Blind-Try + cbMu.RLock() + rooms := append([]ChaturbateRoom(nil), cb.Rooms...) + cbMu.RUnlock() + + showByUser := map[string]string{} + for _, r := range rooms { + u := normUser(r.Username) + if u == "" { + continue } - } else { + showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) + } + + show := strings.TrimSpace(showByUser[it.userKey]) + isPublic := strings.Contains(show, "public") + + if isPublic { + lastTry[it.userKey] = time.Now() + + job, err := startRecordingInternal(RecordRequest{ + URL: it.url, + Cookie: lastCookieHdr, + }) + if err != nil { + if verboseLogs() { + fmt.Println("❌ [autostart] start failed:", it.url, err) + } + continue + } + if verboseLogs() { fmt.Println("▶️ [autostart] started:", it.url) } - // optional auch hier absichern; schadet nicht if job != nil { go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) } + } else { + lastBlindTry[it.userKey] = time.Now() - lastStart = time.Now() + job, err := startRecordingInternal(RecordRequest{ + URL: it.url, + Cookie: lastCookieHdr, + Hidden: true, + }) + if err != nil || job == nil { + if verboseLogs() { + fmt.Println("❌ [autostart] blind start failed:", it.url, err) + } + continue + } + + if verboseLogs() { + fmt.Println("▶️ [autostart] blind try started:", it.url) + } + + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) } } - - time.Sleep(pollInterval) } } diff --git a/backend/models_store.go b/backend/models_store.go index 0c2f141..1d5cf93 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -1498,7 +1498,8 @@ ORDER BY updated_at DESC; rows, err = s.db.Query(` SELECT id,input,COALESCE(host,'') as host,model_key,watching FROM models -WHERE watching = true AND host = $1 +WHERE watching = true + AND lower(trim(COALESCE(host,''))) = lower(trim($1)) ORDER BY updated_at DESC; `, hostFilter) } diff --git a/backend/record.go b/backend/record.go index 1957f98..7db9175 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1495,6 +1495,53 @@ func recordStop(w http.ResponseWriter, r *http.Request) { respondJSON(w, job) } +func recordStopAll(w http.ResponseWriter, r *http.Request) { + if !mustMethod(w, r, http.MethodPost) { + return + } + + type stopAllResp struct { + OK bool `json:"ok"` + Stopped int `json:"stopped"` + IDs []string `json:"ids,omitempty"` + } + + jobsMu.RLock() + toStop := make([]*RecordJob, 0, len(jobs)) + ids := make([]string, 0, len(jobs)) + + for _, job := range jobs { + if job == nil { + continue + } + + // nur aktive echte Downloads stoppen + if isPostworkJob(job) { + continue + } + if isTerminalJobStatus(job.Status) { + continue + } + if job.EndedAt != nil { + continue + } + + toStop = append(toStop, job) + ids = append(ids, job.ID) + } + jobsMu.RUnlock() + + if len(toStop) > 0 { + stopJobsInternal(toStop) + } + + respondJSON(w, stopAllResp{ + OK: true, + Stopped: len(toStop), + IDs: ids, + }) +} + // ---------------- Done index cache ---------------- type doneIndexItem struct { @@ -2253,30 +2300,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } c := *base - - if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { - c.SizeBytes = fi.Size() - } - - id := stripHotPrefix(strings.TrimSuffix(filepath.Base(c.Output), filepath.Ext(c.Output))) - if id != "" { - if mp, err := generatedMetaFile(id); err == nil { - if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() { - if dur, w, h, fps, ok := readVideoMeta(mp, fi); ok { - c.DurationSeconds = dur - c.VideoWidth = w - c.VideoHeight = h - c.FPS = fps - } - if u, ok := readVideoMetaSourceURL(mp, fi); ok && strings.TrimSpace(c.SourceURL) == "" { - c.SourceURL = u - } - } - } - } - applyPreviewSpriteTruthToRecordJobMeta(&c) - applyFinishedPhaseTruthToRecordJobMeta(&c) out = append(out, &c) } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 04a11bd..193b6b9 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -7,24 +7,21 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" - "os" "strconv" "strings" "time" - - "github.com/grafov/m3u8" ) // RecordStream für Chaturbate: -// bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist. -// Stattdessen wie in der alten, funktionierenden Version: +// bewusst WIEDER mit ffmpeg-Input auf die HLS-Playlist. +// Wichtig: // - Seite laden // - HLS URL parsen -// - beste Variant-Playlist auswählen -// - Segmente selbst laden und in .ts schreiben +// - beste Playlist bestimmen +// - wenn separate Audio-Group vorhanden ist, Master-Playlist an ffmpeg geben +// - ffmpeg übernimmt Video + beste verfügbare Audio-Spur func RecordStream( ctx context.Context, hc *HTTPClient, @@ -38,11 +35,10 @@ func RecordStream( if username == "" { return errors.New("leerer username") } - base := strings.TrimRight(domain, "/") pageURL := base + "/" + username + "/" - loadFreshPlaylist := func() (*cbPlaylist, error) { + loadFreshHLS := func() (*selectedHLSStream, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return nil, fmt.Errorf("seite laden: %w", err) @@ -58,7 +54,7 @@ func RecordStream( return nil, errors.New("leere hls url") } - finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders( + stream, err := getWantedResolutionPlaylistWithHeaders( ctx, hlsURL, httpCookie, @@ -68,16 +64,14 @@ func RecordStream( if err != nil { return nil, fmt.Errorf("variant-playlist: %w", err) } - - finalPlaylistURL = strings.TrimSpace(finalPlaylistURL) - if finalPlaylistURL == "" { - return nil, errors.New("leere final playlist url") + if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { + return nil, errors.New("leere final video url") } - return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie) + return stream, nil } - playlist, err := loadFreshPlaylist() + stream, err := loadFreshHLS() if err != nil { return err } @@ -89,7 +83,11 @@ func RecordStream( } jobsMu.Lock() - job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) + previewURL := strings.TrimSpace(stream.VideoURL) + if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { + previewURL = strings.TrimSpace(stream.MasterURL) + } + job.PreviewM3U8 = previewURL job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() @@ -99,62 +97,13 @@ 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 = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment) + err = handleM3U8Mode(ctx, stream, outputPath, job, httpCookie, hc.userAgent, pageURL) if err == nil { - break + return nil } if ctx.Err() != nil { @@ -165,14 +114,14 @@ func RecordStream( shouldRefresh := strings.Contains(msg, "403") || strings.Contains(msg, "401") || - strings.Contains(msg, "playlist nicht mehr erreichbar") || - strings.Contains(msg, "fehlerhafte playlist") || - strings.Contains(msg, "keine neuen hls-segmente") || + 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 fmt.Errorf("watch segments: %w", err) + return err } refreshes++ @@ -180,25 +129,14 @@ func RecordStream( return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) } - fmt.Println("🔄 [cb] refresh playlist:", - "user=", username, - "try=", refreshes, - "reason=", err, - ) - select { case <-ctx.Done(): return ctx.Err() case <-time.After(1500 * time.Millisecond): } - newPlaylist, rerr := loadFreshPlaylist() + newStream, rerr := loadFreshHLS() if rerr != nil { - fmt.Println("⚠️ [cb] refresh failed:", - "user=", username, - "try=", refreshes, - "err=", rerr, - ) select { case <-ctx.Done(): @@ -208,25 +146,20 @@ func RecordStream( continue } - playlist = newPlaylist + stream = newStream if job != nil { jobsMu.Lock() - job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) + previewURL := strings.TrimSpace(stream.VideoURL) + if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { + previewURL = strings.TrimSpace(stream.MasterURL) + } + job.PreviewM3U8 = previewURL 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) @@ -255,212 +188,6 @@ func ParseStream(html string) (string, error) { return rd.HLSSource, nil } -// 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 == "" { diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index c00cdb8..b93890a 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -20,16 +20,109 @@ import ( "github.com/grafov/m3u8" ) +type selectedHLSStream struct { + MasterURL string + VideoURL string + AudioURL string + HasSeparateAudio bool + Height int + FPS float64 +} + +type hlsAltMedia struct { + Type string + GroupID string + Name string + URI string +} + +func parseHLSAttributeList(s string) map[string]string { + out := map[string]string{} + s = strings.TrimSpace(s) + + for len(s) > 0 { + eq := strings.IndexByte(s, '=') + if eq <= 0 { + break + } + + key := strings.TrimSpace(s[:eq]) + rest := s[eq+1:] + + var val string + if strings.HasPrefix(rest, `"`) { + rest = rest[1:] + end := strings.IndexByte(rest, '"') + if end == -1 { + val = rest + s = "" + } else { + val = rest[:end] + rest = rest[end+1:] + if strings.HasPrefix(rest, ",") { + rest = rest[1:] + } + s = rest + } + } else { + comma := strings.IndexByte(rest, ',') + if comma == -1 { + val = rest + s = "" + } else { + val = rest[:comma] + s = rest[comma+1:] + } + } + + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if key != "" { + out[key] = val + } + } + + return out +} + +func parseAltAudioFromMaster(masterText string) []hlsAltMedia { + lines := strings.Split(masterText, "\n") + out := make([]hlsAltMedia, 0, 8) + + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "#EXT-X-MEDIA:") { + continue + } + + attrText := strings.TrimPrefix(line, "#EXT-X-MEDIA:") + attrs := parseHLSAttributeList(attrText) + + if !strings.EqualFold(strings.TrimSpace(attrs["TYPE"]), "AUDIO") { + continue + } + + out = append(out, hlsAltMedia{ + Type: strings.TrimSpace(attrs["TYPE"]), + GroupID: strings.TrimSpace(attrs["GROUP-ID"]), + Name: strings.TrimSpace(attrs["NAME"]), + URI: strings.TrimSpace(attrs["URI"]), + }) + } + + return out +} + func getWantedResolutionPlaylistWithHeaders( ctx context.Context, playlistURL string, httpCookie string, userAgent string, referer string, -) (string, error) { +) (*selectedHLSStream, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil) if err != nil { - return "", err + return nil, err } if strings.TrimSpace(userAgent) != "" { @@ -48,31 +141,54 @@ func getWantedResolutionPlaylistWithHeaders( resp, err := http.DefaultClient.Do(req) if err != nil { - return "", err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } - playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + rawBody, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("m3u8 parse: %w", err) + return nil, fmt.Errorf("m3u8 read: %w", err) } + masterText := string(rawBody) + + playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true) + if err != nil { + return nil, fmt.Errorf("m3u8 parse: %w", err) + } + + // Schon Media-Playlist if listType == m3u8.MEDIA { - return playlistURL, nil + return &selectedHLSStream{ + MasterURL: playlistURL, + VideoURL: playlistURL, + AudioURL: "", + HasSeparateAudio: false, + Height: 0, + FPS: 0, + }, nil } master, ok := playlist.(*m3u8.MasterPlaylist) if !ok || master == nil { - return "", errors.New("unerwarteter playlist-typ: keine master-playlist") + return nil, errors.New("unerwarteter playlist-typ: keine master-playlist") + } + + altAudio := parseAltAudioFromMaster(masterText) + + baseURL, err := url.Parse(playlistURL) + if err != nil { + return nil, err } var bestURI string var bestHeight int var bestFramerate float64 + var bestAudioGroup string for _, v := range master.Variants { if v == nil || strings.TrimSpace(v.URI) == "" { @@ -94,30 +210,102 @@ func getWantedResolutionPlaylistWithHeaders( fr = v.FrameRate } - if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) { + audioGroup := strings.TrimSpace(v.Audio) + + shouldTake := false + switch { + case bestURI == "": + shouldTake = true + case h > bestHeight: + shouldTake = true + case h == bestHeight && fr > bestFramerate: + shouldTake = true + } + + if shouldTake { bestURI = strings.TrimSpace(v.URI) bestHeight = h bestFramerate = fr + bestAudioGroup = audioGroup } } if bestURI == "" { - return "", errors.New("Master-Playlist ohne gültige Varianten") - } - - baseURL, err := url.Parse(playlistURL) - if err != nil { - return "", err + return nil, errors.New("Master-Playlist ohne gültige Varianten") } refURL, err := url.Parse(bestURI) if err != nil { - return "", err + return nil, err + } + videoURL := baseURL.ResolveReference(refURL).String() + + audioURL := "" + hasSeparateAudio := false + + if bestAudioGroup != "" { + hasSeparateAudio = true + + var bestAudioURI string + var bestAudioName string + + for _, a := range altAudio { + if strings.TrimSpace(a.GroupID) != bestAudioGroup { + continue + } + if strings.TrimSpace(a.URI) == "" { + continue + } + + name := strings.ToLower(strings.TrimSpace(a.Name)) + uri := strings.TrimSpace(a.URI) + + if bestAudioURI == "" { + bestAudioURI = uri + bestAudioName = name + continue + } + + score := func(s string) int { + n := strings.ToLower(strings.TrimSpace(s)) + points := 0 + if strings.Contains(n, "128") { + points += 100 + } + if strings.Contains(n, "high") { + points += 20 + } + if strings.Contains(n, "aac") { + points += 10 + } + return points + } + + if score(name) > score(bestAudioName) { + bestAudioURI = uri + bestAudioName = name + } + } + + if bestAudioURI == "" { + hasSeparateAudio = false + } else { + audioRef, err := url.Parse(bestAudioURI) + if err != nil { + return nil, err + } + audioURL = baseURL.ResolveReference(audioRef).String() + } } - finalURL := baseURL.ResolveReference(refURL).String() - - return finalURL, nil + return &selectedHLSStream{ + MasterURL: playlistURL, + VideoURL: videoURL, + AudioURL: audioURL, + HasSeparateAudio: hasSeparateAudio, + Height: bestHeight, + FPS: bestFramerate, + }, nil } func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { @@ -148,16 +336,30 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { func handleM3U8Mode( ctx context.Context, - m3u8URL string, + stream *selectedHLSStream, outFile string, job *RecordJob, httpCookie string, userAgent string, referer string, ) error { - u, err := url.Parse(m3u8URL) + if stream == nil { + return errors.New("stream config nil") + } + + videoURL := strings.TrimSpace(stream.VideoURL) + audioURL := strings.TrimSpace(stream.AudioURL) + + u, err := url.Parse(videoURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return fmt.Errorf("ungültige URL: %q", m3u8URL) + return fmt.Errorf("ungültige video URL: %q", videoURL) + } + + if audioURL != "" { + au, err := url.Parse(audioURL) + if err != nil || (au.Scheme != "http" && au.Scheme != "https") { + return fmt.Errorf("ungültige audio URL: %q", audioURL) + } } if strings.TrimSpace(outFile) == "" { @@ -177,32 +379,49 @@ func handleM3U8Mode( 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) + appendInputOptions := func(args []string) []string { + 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) + } + return args } 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", - ) + if audioURL != "" { + args = appendInputOptions(args) + args = append(args, "-i", videoURL) + + args = appendInputOptions(args) + args = append(args, "-i", audioURL) + + args = append(args, + "-map", "0:v:0", + "-map", "1:a:0?", + "-dn", + "-ignore_unknown", + "-c", "copy", + ) + } else { + args = appendInputOptions(args) + args = append(args, + "-i", videoURL, + "-map", "0:v:0", + "-map", "0:a:0?", + "-dn", + "-ignore_unknown", + "-c", "copy", + ) + } switch ext { case ".ts": @@ -219,8 +438,6 @@ func handleM3U8Mode( 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 diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index fef19f1..dc0f625 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -64,18 +64,23 @@ func RecordStreamMFC( } // ✅ erst jetzt die Video URL holen (weil public) - m3u8URL, err := mfc.GetVideoURL(false) + stream, err := mfc.GetSelectedStream(false) if err != nil { return fmt.Errorf("mfc get video url: %w", err) } - if strings.TrimSpace(m3u8URL) == "" { + if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return fmt.Errorf("mfc: keine m3u8 URL gefunden") } // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input if job != nil { + previewURL := strings.TrimSpace(stream.VideoURL) + if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { + previewURL = strings.TrimSpace(stream.MasterURL) + } + jobsMu.Lock() - job.PreviewM3U8 = strings.TrimSpace(m3u8URL) + job.PreviewM3U8 = previewURL job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen job.PreviewUA = hc.userAgent jobsMu.Unlock() @@ -87,7 +92,7 @@ func RecordStreamMFC( } // Aufnahme starten - return handleM3U8Mode(ctx, m3u8URL, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) + return handleM3U8Mode(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) } type MyFreeCams struct { @@ -144,6 +149,24 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { return m.VideoURL, nil } +func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error) { + m3u8URL, err := m.GetVideoURL(refresh) + if err != nil { + return nil, err + } + if strings.TrimSpace(m3u8URL) == "" { + return nil, errors.New("keine m3u8 URL gefunden") + } + + return getWantedResolutionPlaylistWithHeaders( + context.Background(), + m3u8URL, + "", // MFC normalerweise ohne Cookies + "", // optionaler UA, hier leer lassen oder später ergänzen + m.GetWebsiteURL(), + ) +} + func (m *MyFreeCams) GetStatus() (Status, error) { // 1) share-Seite prüfen (existiert/nicht existiert) shareURL := "https://share.myfreecams.com/" + m.Username @@ -235,15 +258,15 @@ func runMFC(ctx context.Context, username string, outArg string) error { return fmt.Errorf("Stream ist nicht live (Status: %s)", st) } - m3u8URL, err := mfc.GetVideoURL(false) + stream, err := mfc.GetSelectedStream(false) if err != nil { return err } - if m3u8URL == "" { + if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return errors.New("keine m3u8 URL gefunden") } - return handleM3U8Mode(ctx, m3u8URL, outArg, nil, "", "", mfc.GetWebsiteURL()) + return handleM3U8Mode(ctx, stream, outArg, nil, "", "", mfc.GetWebsiteURL()) } func getWantedResolutionPlaylist(playlistURL string) (string, error) { diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 44d5b99..8e74ac6 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -16,5 +16,5 @@ "teaserPlayback": "all", "teaserAudio": true, "enableNotifications": true, - "encryptedCookies": "yDldmKNsPH20hdVRPLdr8nVj4CoIC/NHiXESzcqLLSsEcgi4P7V7+xcKw2d3I/GnGR6uVNQYBrc4c+YeUs9J7wbn9Ir+BdaFDAWZVWBoaHgdcgYLaKUxIMO273zfi+jwkPF4DHuSBw9gkAHgKeF15H+azEQEDjJc8VH1C+7/F6qhkHBtFKKDiAkj8a4+cqmzr6Cr+tdraCaTxu4YZmqA/3erMRl/HA6egQFAcfGB2KAcfBqQagvXZ+R8SgfTwJiLG7RkH5Xdh3fT34Cc03sbyXCJxMzFUd4RKgCRyM2oUfqXq/oOL8AbZxm0oi2Mh+8TaqbAfdu1CstnPlAFAk7s3gMvp4Ni5QVwtfKQYzSYThKyaZSV2wnWZIWOXh9Zb3SvmAQBANJUHG9WUr/nyxqDPj03bIKNWA8/ZflAyVtCQtlpQU4Nrqe3Fhl/ALBuhgTcwx8X77rs1Py7sR9SJ8Ks+bKf8ziE04oQem6HQsIf1TAZTpYbpd7mD7NrbUuDtbJ3RabZMppygQc=" + "encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A==" } diff --git a/backend/routes.go b/backend/routes.go index e8d6abc..c823f87 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -46,6 +46,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record", startRecordingFromRequest) api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/stop", recordStop) + api.HandleFunc("/api/record/stop-all", recordStopAll) api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork) api.HandleFunc("/api/preview", recordPreview) api.HandleFunc("/api/preview/live", recordPreviewLive) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13a5910..ded3142 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -826,16 +826,11 @@ export default function App() { donePrefetchRef.current = null - const totalPages = getDoneTotalPages(doneCount, donePageSize) - if (pageToLoad < totalPages) { - void prefetchDonePage(pageToLoad + 1) - } - return } const res = await fetch( - `/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}${ + `/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}&withCount=1${ includeKeep ? '&includeKeep=1' : '' }`, { cache: 'no-store' as any } @@ -846,13 +841,11 @@ export default function App() { const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) - : Array.isArray(data) - ? (data as RecordJob[]) - : [] + : [] setDoneJobs(items) - const countRaw = Number(data?.count ?? data?.totalCount ?? doneCount) + const countRaw = Number(data?.count ?? doneCount) const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 if (count !== doneCount) { @@ -959,13 +952,9 @@ export default function App() { useEffect(() => { if (!authed) return - void loadDoneCount() - }, [authed, loadDoneCount]) - - useEffect(() => { - if (!authed) return + if (selectedTab !== 'finished') return void loadDonePage(donePage, doneSort) - }, [authed, donePage, doneSort, loadDonePage]) + }, [authed, selectedTab, donePage, doneSort, loadDonePage]) const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const h = String(m?.host ?? '').toLowerCase() @@ -1765,6 +1754,16 @@ export default function App() { }, []) const selectedTabRef = useRef(selectedTab) + const donePageRef = useRef(donePage) + const doneSortRef = useRef(doneSort) + + useEffect(() => { + donePageRef.current = donePage + }, [donePage]) + + useEffect(() => { + doneSortRef.current = doneSort + }, [doneSort]) const applyPendingRoomSnapshot = useCallback( ( @@ -2102,8 +2101,8 @@ export default function App() { }) if (alreadyRunning) return true - // ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status - if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { + // ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart + if (silent && provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { try { const parsed = await apiJSON('/api/models/parse', { method: 'POST', @@ -2114,12 +2113,6 @@ export default function App() { const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() if (mkLower) { - // silent: direkt in Queue legen, Status kommt später über den Poller - if (silent) { - queuePendingAutoStart(mkLower, norm, 'unknown') - return true - } - let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown' let resolvedImageUrl: string | undefined let resolvedChatRoomUrl: string | undefined @@ -2168,27 +2161,20 @@ export default function App() { 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 + // public => unten normal starten } } catch { - if (silent) { - const mkLower = providerKeyLowerFromUrl(norm) - if (mkLower) { - queuePendingAutoStart(mkLower, norm, 'unknown') - return true - } + const mkLower = providerKeyLowerFromUrl(norm) + if (mkLower) { + queuePendingAutoStart(mkLower, norm, 'unknown') + return true } } } - if (busyRef.current) return false - setBusy(true) - busyRef.current = true - try { const cookieString = Object.entries(currentCookies) .map(([k, v]) => `${k}=${v}`) @@ -2221,9 +2207,6 @@ export default function App() { if (!silent) setError(msg) return false - } finally { - setBusy(false) - busyRef.current = false } }, []) @@ -2338,7 +2321,7 @@ export default function App() { }, ] - const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy]) + const canStart = useMemo(() => sourceUrl.trim().length > 0, [sourceUrl]) // Cookies load/save useEffect(() => { @@ -2412,9 +2395,12 @@ export default function App() { fallbackTimer = window.setInterval(() => { if (document.hidden) return - void loadDoneCount() void loadJobs() void loadTaskStateOnce() + + if (selectedTabRef.current === 'finished') { + void loadDonePage(donePageRef.current, doneSortRef.current) + } }, document.hidden ? 60000 : 5000) } @@ -2448,7 +2434,8 @@ export default function App() { } const onDoneChanged = () => { - void loadDoneCount() + if (selectedTabRef.current !== 'finished') return + void loadDonePage(donePageRef.current, doneSortRef.current) } const onAutostart = (ev: MessageEvent) => { @@ -2547,8 +2534,11 @@ export default function App() { const onVis = () => { if (document.hidden) return void loadJobs() - void loadDoneCount() void loadTaskStateOnce() + + if (selectedTabRef.current === 'finished') { + void loadDonePage(donePageRef.current, doneSortRef.current) + } } document.addEventListener('visibilitychange', onVis) @@ -2660,6 +2650,14 @@ export default function App() { } } + async function stopAllJobs() { + try { + await apiJSON('/api/record/stop-all', { method: 'POST' }) + } catch (e: any) { + notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) + } + } + async function removeQueuedPostworkJob(id: string) { try { await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, { @@ -2678,15 +2676,11 @@ export default function App() { if (Number.isFinite(delta) && delta !== 0) { setDoneCount((c) => Math.max(0, c + delta)) } - - // ✅ Nur Header/Badge nachziehen - // FinishedDownloads macht seinen Refill selbst. - void loadDoneCount() } window.addEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) - }, [loadDoneCount]) + }, []) useEffect(() => { const onNav = (ev: Event) => { @@ -2718,7 +2712,16 @@ export default function App() { }, [playerJob, modelsByKey]) async function onStart() { - return startUrl(sourceUrl) + const ok = enqueueStart({ + url: sourceUrl, + silent: false, + }) + + if (ok) { + setSourceUrl('') + } + + return ok } const handleAddToDownloads = useCallback( @@ -3552,6 +3555,7 @@ export default function App() { }} onOpenPlayer={openPlayer} onStopJob={stopJob} + onStopAllJobs={stopAllJobs} onRemoveQueuedPostworkJob={removeQueuedPostworkJob} onToggleFavorite={handleToggleFavorite} onToggleLike={handleToggleLike} diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 0446bbb..10a4e75 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -42,6 +42,7 @@ type Props = { roomStatusByModelKey?: Record onOpenPlayer: (job: RecordJob) => void onStopJob: (id: string) => void | Promise + onStopAllJobs?: () => void | Promise onRemoveQueuedPostworkJob?: (id: string) => void | Promise blurPreviews?: boolean onToggleFavorite?: (job: RecordJob) => void | Promise @@ -531,6 +532,7 @@ export default function Downloads({ onRefreshAutostartState, onOpenPlayer, onStopJob, + onStopAllJobs, onToggleFavorite, onToggleLike, onToggleWatch, @@ -1325,11 +1327,16 @@ export default function Downloads({ setStopAllBusy(true) try { markStopRequested(stoppableIds) - await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id)))) + + if (onStopAllJobs) { + await onStopAllJobs() + } else { + await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id)))) + } } finally { setStopAllBusy(false) } - }, [stopAllBusy, stoppableIds, markStopRequested, onStopJob]) + }, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob]) const rowClassName = useCallback((r: DownloadRow) => { if (r.kind === 'pending') { diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index 990a103..8ec34a5 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -304,7 +304,6 @@ export default function FinishedDownloadsTableView({ }} > {/* Scrub-Frame Overlay */} @@ -361,8 +360,7 @@ export default function FinishedDownloadsTableView({ const tags = parseTags(modelsByKey[modelKey]?.tags) const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) - const showPostworkBadge = - !!postworkBadge && postworkBadge.state !== 'missing' + const showPostworkBadge = !!postworkBadge return (
@@ -371,9 +369,11 @@ export default function FinishedDownloadsTableView({ {model}
- {showPostworkBadge && renderPostworkBadge - ? renderPostworkBadge(postworkBadge) - : null} + {showPostworkBadge && renderPostworkBadge ? ( +
+ {renderPostworkBadge(postworkBadge)} +
+ ) : null}
@@ -614,15 +614,6 @@ export default function FinishedDownloadsTableView({ sort={sort} onSortChange={handleSortChange} onRowClick={onOpenPlayer} - onRowMouseEnter={(j) => { - if (canHover) setHoverTeaserKey(keyFor(j)) - }} - onRowMouseLeave={(j) => { - if (canHover) { - setHoverTeaserKey((prev) => (prev === keyFor(j) ? null : prev)) - } - setScrubActiveIndex(keyFor(j), undefined) - }} rowAttrs={(j) => ({ 'data-finished-hover-key': keyFor(j), })} diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index b95f274..d51e3c1 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -6,6 +6,7 @@ import type { RecordJob } from '../../types' import HoverPopover from './HoverPopover' import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy' import { ForwardIcon } from '@heroicons/react/24/solid' +import LoadingSpinner from './LoadingSpinner' type Variant = 'thumb' | 'fill' type InlineVideoMode = false | true | 'always' | 'hover' @@ -311,9 +312,6 @@ export default function FinishedVideoPreview({ const [inView, setInView] = useState(false) const [nearView, setNearView] = useState(false) - // ✅ sobald einmal im Viewport gewesen -> true (damit wir danach nicht wieder entladen) - const [everInView, setEverInView] = useState(false) - // Tick nur für frames-Mode const [localTick, setLocalTick] = useState(0) @@ -327,7 +325,6 @@ export default function FinishedVideoPreview({ const effectiveInView = forceActive || inView const effectiveNearView = forceActive || nearView - const effectiveEverInView = forceActive || everInView const inlineMode: 'never' | 'always' | 'hover' = inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never' @@ -581,7 +578,6 @@ export default function FinishedVideoPreview({ (entries) => { const hit = Boolean(entries[0]?.isIntersecting) setInView(hit) - if (hit) setEverInView(true) }, { threshold: 0.01, @@ -684,7 +680,6 @@ export default function FinishedVideoPreview({ const teaserActive = animated && effectiveInView && - pageVisible && videoOk && !showingInlineVideo && (animatedTrigger === 'always' || hovered) && @@ -705,11 +700,11 @@ export default function FinishedVideoPreview({ // ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating) const shouldLoadStill = - forceActive || alwaysLoadStill || effectiveInView || effectiveEverInView || (wantsHover && hovered) + forceActive || alwaysLoadStill || effectiveInView || (wantsHover && hovered) // ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben const shouldPreloadAnimatedAssets = - forceActive || effectiveNearView || effectiveInView || effectiveEverInView || (wantsHover && hovered) + forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered) useEffect(() => { if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) { @@ -815,7 +810,22 @@ export default function FinishedVideoPreview({ const showStillImage = shouldLoadStill && !showingInlineVideo && - !(animatedMode === 'teaser' && teaserActive) + !( + animatedMode === 'teaser' && + teaserActive && + teaserReady + ) + + const teaserActivatedSoftly = + (animatedTrigger === 'hover' && hovered) || forceActive + + const showTeaserLoadingOverlay = + !showingInlineVideo && + animatedMode === 'teaser' && + teaserActive && + teaserOk && + !teaserReady && + !teaserActivatedSoftly // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig) const progressVideoRef = @@ -932,10 +942,6 @@ export default function FinishedVideoPreview({ const active = teaserActive && animatedMode === 'teaser' - if (!active) { - setTeaserPlaybackRate(1) - } - const clearRetryTimer = () => { if (teaserPlayRetryTimerRef.current != null) { window.clearTimeout(teaserPlayRetryTimerRef.current) @@ -944,6 +950,17 @@ export default function FinishedVideoPreview({ } if (!active) { + setTeaserPlaybackRate(1) + clearRetryTimer() + try { + vv.pause() + } catch {} + return + } + + // Tab hidden => nur pausieren, aber NICHT unmounten + if (!pageVisible) { + setTeaserPlaybackRate(1) clearRetryTimer() try { vv.pause() @@ -1086,7 +1103,7 @@ export default function FinishedVideoPreview({ if (typeof raf2 === 'number') cancelAnimationFrame(raf2) window.clearInterval(watchdog) } - }, [teaserActive, animatedMode, teaserSrc, muted, pageVisible]) + }, [teaserActive, animatedMode, teaserSrc, muted, pageVisible, teaserSpeedHold, setTeaserPlaybackRate]) useEffect(() => { if (!activationNonce) return @@ -1321,6 +1338,22 @@ export default function FinishedVideoPreview({
)} + {showTeaserLoadingOverlay ? ( +
+
+ + Lade Teaser… +
+
+ ) : null} + {/* ✅ Inline Full Video (nur wenn sichtbar/aktiv) */} {showingInlineVideo ? (