This commit is contained in:
Linrador 2026-04-07 11:46:42 +02:00
parent fb4f196994
commit 144a8492d7
14 changed files with 819 additions and 668 deletions

View File

@ -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,11 +231,17 @@ 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 {
select {
case <-scanTicker.C:
if isAutostartPaused() {
time.Sleep(2 * time.Second)
continue
}
@ -218,15 +249,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if !s.UseChaturbateAPI {
queue = queue[:0]
queued = map[string]bool{}
time.Sleep(2 * time.Second)
lastCookieHdr = ""
continue
}
cookieHdr := cookieHeaderFromSettings(s)
if !hasChaturbateCookies(cookieHdr) {
time.Sleep(5 * time.Second)
lastCookieHdr = ""
continue
}
lastCookieHdr = cookieHdr
// online snapshot aus cache
cbMu.RLock()
@ -260,13 +292,43 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
jobsMu.RUnlock()
// watched list aus DB
watched := store.ListWatchedLite("chaturbate.com")
// bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben
watched := store.ListWatchedLite("")
watchedByUser := map[string]WatchedModelLite{}
for _, m := range watched {
key := normUser(m.ModelKey)
if key != "" && m.Watching {
watchedByUser[key] = m
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
@ -298,7 +360,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
// 1) watched + API sagt public => normal enqueue
// ------------------------------------------------------------
for user, m := range watchedByUser {
if showByUser[user] != "public" {
show := strings.ToLower(strings.TrimSpace(showByUser[user]))
if show == "" {
continue
}
if !strings.Contains(show, "public") {
continue
}
if running[user] {
@ -324,11 +390,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
// ------------------------------------------------------------
// 2) watched + NICHT in API => blind best-effort try wie MFC
// aber nur wenn:
// - nicht schon running
// - nicht schon queued
// - nicht kürzlich versucht
// 2) watched + NICHT in API => blind best-effort try vormerken
// ------------------------------------------------------------
for user, m := range watchedByUser {
if seenInAPI[user] {
@ -349,65 +411,99 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
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,
queue = append(queue, autoStartItem{
userKey: user,
url: u,
})
if err != nil || job == nil {
if verboseLogs() {
fmt.Println("❌ [autostart] blind start failed:", u, err)
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)
// 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
}
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: cookieHdr,
Cookie: lastCookieHdr,
})
if err != nil {
if verboseLogs() {
fmt.Println("❌ [autostart] start failed:", it.url, err)
}
} else {
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
}
time.Sleep(pollInterval)
if verboseLogs() {
fmt.Println("▶️ [autostart] blind try started:", it.url)
}
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
}
}
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 == "" {

View File

@ -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
}
finalURL := baseURL.ResolveReference(refURL).String()
name := strings.ToLower(strings.TrimSpace(a.Name))
uri := strings.TrimSpace(a.URI)
return finalURL, nil
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()
}
}
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)
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)))
if audioURL != "" {
args = appendInputOptions(args)
args = append(args, "-i", videoURL)
args = appendInputOptions(args)
args = append(args, "-i", audioURL)
args = append(args,
"-i", m3u8URL,
"-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

View File

@ -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) {

View File

@ -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=="
}

View File

@ -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)

View File

@ -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<ParsedModel>('/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,14 +2161,12 @@ 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')
@ -2183,11 +2174,6 @@ export default function App() {
}
}
}
}
if (busyRef.current) return false
setBusy(true)
busyRef.current = true
try {
const cookieString = Object.entries(currentCookies)
@ -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}

View File

@ -42,6 +42,7 @@ type Props = {
roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void>
onStopAllJobs?: () => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
@ -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)
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') {

View File

@ -304,7 +304,6 @@ export default function FinishedDownloadsTableView({
}}
>
<FinishedVideoPreview
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={jobForDetails(j)}
getFileName={baseName}
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
@ -323,7 +322,7 @@ export default function FinishedDownloadsTableView({
animatedTrigger="always"
assetNonce={assetNonce}
forceActive={isPreviewActive}
teaserPreloadEnabled
teaserPreloadEnabled={false}
/>
{/* 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 (
<div className="min-w-0">
@ -371,9 +369,11 @@ export default function FinishedDownloadsTableView({
{model}
</div>
{showPostworkBadge && renderPostworkBadge
? renderPostworkBadge(postworkBadge)
: null}
{showPostworkBadge && renderPostworkBadge ? (
<div className="relative z-20 shrink-0">
{renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400">
@ -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),
})}

View File

@ -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({
<div className="absolute inset-0 bg-black/10 dark:bg-white/10" />
)}
{showTeaserLoadingOverlay ? (
<div className="absolute inset-0 z-20 grid place-items-center pointer-events-none">
<div
className="
flex items-center gap-2 rounded-lg
bg-black/45 px-3 py-2
text-white backdrop-blur-sm
ring-1 ring-white/10
"
>
<LoadingSpinner size="sm" className="shrink-0" srLabel="Teaser lädt…" />
<span className="text-xs font-medium">Lade Teaser</span>
</div>
</div>
) : null}
{/* ✅ Inline Full Video (nur wenn sichtbar/aktiv) */}
{showingInlineVideo ? (
<video
@ -1379,7 +1412,6 @@ export default function FinishedVideoPreview({
teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el)
}}
key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
src={teaserSrc}
className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none',
@ -1393,7 +1425,7 @@ export default function FinishedVideoPreview({
playsInline
autoPlay
loop
preload={teaserReady ? 'auto' : 'metadata'}
preload="auto"
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => {
setTeaserOk(true)

View File

@ -28,6 +28,17 @@ export default function LiveVideo({
const [broken, setBroken] = useState(false)
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null)
const onLoadingStartRef = useRef(onLoadingStart)
const onReadyRef = useRef(onReady)
useEffect(() => {
onLoadingStartRef.current = onLoadingStart
}, [onLoadingStart])
useEffect(() => {
onReadyRef.current = onReady
}, [onReady])
const normalizeBrokenReason = (
status?: string
): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => {
@ -89,12 +100,12 @@ export default function LiveVideo({
return
}
onLoadingStart?.()
onLoadingStartRef.current?.()
const markReady = () => {
if (readySent) return
readySent = true
onReady?.()
onReadyRef.current?.()
}
video.src = src
@ -131,7 +142,7 @@ export default function LiveVideo({
if (stalledRetries < 1) {
stalledRetries += 1
readySent = false
onLoadingStart?.()
onLoadingStartRef.current?.()
hardReset()
video.src = src
@ -175,7 +186,7 @@ export default function LiveVideo({
video.removeEventListener('error', onError)
hardReset()
}
}, [src, roomStatus, muted, volume, onLoadingStart, onReady])
}, [src, roomStatus])
useEffect(() => {
const video = ref.current

View File

@ -1,7 +1,7 @@
// frontend/src/components/ui/ModelPreview.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveVideo from './LiveVideo'
import {
@ -54,7 +54,6 @@ export default function ModelPreview({
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
const [pageVisible, setPageVisible] = useState(!document.hidden)
const [localTick, setLocalTick] = useState(0)
const [imgError, setImgError] = useState(false)
const [liveSrc, setLiveSrc] = useState('')
const [hoverOpen, setHoverOpen] = useState(false)
@ -65,20 +64,34 @@ export default function ModelPreview({
const [popupMuted, setPopupMuted] = useState(true)
const [popupVolume, setPopupVolume] = useState(1)
const handleLiveLoadingStart = useCallback(() => {
setLiveReady(false)
}, [])
const handleLiveReady = useCallback(() => {
setLiveReady(true)
}, [])
const handleLiveVolumeChange = useCallback((nextVolume: number, nextMuted: boolean) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}, [])
const blurCls = blur ? 'blur-md' : ''
const objectFitCls = fit === 'contain' ? 'object-contain' : 'object-cover'
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
const tick = live
? (typeof thumbTick === 'number' ? thumbTick : localTick)
: 0
const [previewVersion, setPreviewVersion] = useState(0)
const [imgLoading, setImgLoading] = useState(false)
const previewSrc = useMemo(() => {
// Nur bereits vorhandenes preview.jpg anfordern, niemals on-the-fly generieren
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${tick}`
}, [jobId, tick])
if (!live) {
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg`
}
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${previewVersion}`
}, [jobId, live, previewVersion])
const fallbackImgSrc = useMemo(() => {
const s = String(fallbackSrc ?? '').trim()
@ -97,6 +110,11 @@ export default function ModelPreview({
return () => document.removeEventListener('visibilitychange', onVis)
}, [])
useEffect(() => {
if (!inView) return
setImgLoading(true)
}, [previewSrc, inView])
useEffect(() => {
const ac = new AbortController()
@ -175,25 +193,25 @@ export default function ModelPreview({
useEffect(() => {
setImgError(false)
setLiveSrc('')
setLivePreparing(false)
setLiveReady(false)
}, [jobId])
useEffect(() => {
setImgError(false)
}, [previewSrc])
useEffect(() => {
if (!live) return
if (typeof thumbTick === 'number') return
if (!inView) return
if (!pageVisible) return
if (imgError) return
if (imgLoading) return
const id = window.setInterval(() => {
setLocalTick((x) => x + 1)
setImgError(false)
const id = window.setTimeout(() => {
setPreviewVersion((x) => x + 1)
}, autoTickMs)
return () => window.clearInterval(id)
}, [live, thumbTick, inView, pageVisible, autoTickMs])
return () => window.clearTimeout(id)
}, [live, thumbTick, inView, pageVisible, autoTickMs, imgError, imgLoading, previewVersion])
return (
<HoverPopover
@ -218,16 +236,9 @@ export default function ModelPreview({
muted={popupMuted}
volume={popupVolume}
roomStatus={roomStatus}
onLoadingStart={() => {
setLiveReady(false)
}}
onReady={() => {
setLiveReady(true)
}}
onVolumeChange={(nextVolume, nextMuted) => {
setPopupVolume(nextVolume)
setPopupMuted(nextMuted)
}}
onLoadingStart={handleLiveLoadingStart}
onReady={handleLiveReady}
onVolumeChange={handleLiveVolumeChange}
className="w-full h-full object-contain object-center relative z-0"
/>
@ -311,8 +322,14 @@ export default function ModelPreview({
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => setImgError(false)}
onError={() => setImgError(true)}
onLoad={() => {
setImgLoading(false)
setImgError(false)
}}
onError={() => {
setImgLoading(false)
setImgError(true)
}}
/>
) : (
<img