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)) 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 { func chaturbateUserFromURL(raw string) string {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if 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://") { if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") {
return in return in
} }
key := strings.Trim(strings.TrimSpace(m.ModelKey), "/") key := strings.Trim(strings.TrimSpace(m.ModelKey), "/")
if key == "" {
key = watchedChaturbateUserKey(m)
}
if key == "" { if key == "" {
return "" return ""
} }
return fmt.Sprintf("https://chaturbate.com/%s/", key) return fmt.Sprintf("https://chaturbate.com/%s/", key)
} }
@ -189,8 +214,8 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
return return
} }
const pollInterval = 5 * time.Second const scanInterval = 1 * time.Second
const startGap = 1500 * time.Millisecond const startInterval = 5 * time.Second
// normaler Retry für API-public // normaler Retry für API-public
const retryCooldown = 25 * time.Second const retryCooldown = 25 * time.Second
@ -206,208 +231,279 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
lastTry := map[string]time.Time{} lastTry := map[string]time.Time{}
lastBlindTry := 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 { for {
if isAutostartPaused() { select {
time.Sleep(2 * time.Second) case <-scanTicker.C:
continue if isAutostartPaused() {
}
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 == "" {
continue continue
} }
seenInAPI[u] = true
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
}
// laufende Jobs sammeln s := getSettings()
running := map[string]bool{} if !s.UseChaturbateAPI {
jobsMu.RLock() queue = queue[:0]
for _, j := range jobs { queued = map[string]bool{}
if !isActiveRecordingJob(j) { lastCookieHdr = ""
continue continue
} }
u := chaturbateUserFromURL(j.SourceURL)
if u != "" {
running[u] = true
}
}
jobsMu.RUnlock()
// watched list aus DB cookieHdr := cookieHeaderFromSettings(s)
watched := store.ListWatchedLite("chaturbate.com") if !hasChaturbateCookies(cookieHdr) {
watchedByUser := map[string]WatchedModelLite{} lastCookieHdr = ""
for _, m := range watched { continue
key := normUser(m.ModelKey) }
if key != "" && m.Watching { 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 watchedByUser[key] = m
} }
}
// queue prune // queue prune
nextQueue := queue[:0] nextQueue := queue[:0]
nextQueued := map[string]bool{} nextQueued := map[string]bool{}
for _, it := range queue { for _, it := range queue {
m, ok := watchedByUser[it.userKey] m, ok := watchedByUser[it.userKey]
if !ok { if !ok {
continue 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)
} }
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 continue
} }
if verboseLogs() { s := getSettings()
fmt.Println("▶️ [autostart] blind try started:", u) if !s.UseChaturbateAPI {
continue
} }
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) if !hasChaturbateCookies(lastCookieHdr) {
lastStart = time.Now() 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] it := queue[0]
queue = queue[1:] queue = queue[1:]
delete(queued, it.userKey) delete(queued, it.userKey)
lastTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{ // prüfen, ob inzwischen schon aktiv
URL: it.url, if isJobRunningForURL(it.url) {
Cookie: cookieHdr, continue
}) }
if err != nil {
if verboseLogs() { // unterscheiden: API-public oder Blind-Try
fmt.Println("❌ [autostart] start failed:", it.url, err) 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() { if verboseLogs() {
fmt.Println("▶️ [autostart] started:", it.url) fmt.Println("▶️ [autostart] started:", it.url)
} }
// optional auch hier absichern; schadet nicht
if job != nil { if job != nil {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) 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)
} }
} }

View File

@ -1498,7 +1498,8 @@ ORDER BY updated_at DESC;
rows, err = s.db.Query(` rows, err = s.db.Query(`
SELECT id,input,COALESCE(host,'') as host,model_key,watching SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models FROM models
WHERE watching = true AND host = $1 WHERE watching = true
AND lower(trim(COALESCE(host,''))) = lower(trim($1))
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
`, hostFilter) `, hostFilter)
} }

View File

@ -1495,6 +1495,53 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
respondJSON(w, job) 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 ---------------- // ---------------- Done index cache ----------------
type doneIndexItem struct { type doneIndexItem struct {
@ -2253,30 +2300,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
} }
c := *base 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) applyPreviewSpriteTruthToRecordJobMeta(&c)
applyFinishedPhaseTruthToRecordJobMeta(&c)
out = append(out, &c) out = append(out, &c)
} }

View File

@ -7,24 +7,21 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/grafov/m3u8"
) )
// RecordStream für Chaturbate: // RecordStream für Chaturbate:
// bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist. // bewusst WIEDER mit ffmpeg-Input auf die HLS-Playlist.
// Stattdessen wie in der alten, funktionierenden Version: // Wichtig:
// - Seite laden // - Seite laden
// - HLS URL parsen // - HLS URL parsen
// - beste Variant-Playlist auswählen // - beste Playlist bestimmen
// - Segmente selbst laden und in .ts schreiben // - wenn separate Audio-Group vorhanden ist, Master-Playlist an ffmpeg geben
// - ffmpeg übernimmt Video + beste verfügbare Audio-Spur
func RecordStream( func RecordStream(
ctx context.Context, ctx context.Context,
hc *HTTPClient, hc *HTTPClient,
@ -38,11 +35,10 @@ func RecordStream(
if username == "" { if username == "" {
return errors.New("leerer username") return errors.New("leerer username")
} }
base := strings.TrimRight(domain, "/") base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username + "/" pageURL := base + "/" + username + "/"
loadFreshPlaylist := func() (*cbPlaylist, error) { loadFreshHLS := func() (*selectedHLSStream, error) {
body, err := hc.FetchPage(ctx, pageURL, httpCookie) body, err := hc.FetchPage(ctx, pageURL, httpCookie)
if err != nil { if err != nil {
return nil, fmt.Errorf("seite laden: %w", err) return nil, fmt.Errorf("seite laden: %w", err)
@ -58,7 +54,7 @@ func RecordStream(
return nil, errors.New("leere hls url") return nil, errors.New("leere hls url")
} }
finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders( stream, err := getWantedResolutionPlaylistWithHeaders(
ctx, ctx,
hlsURL, hlsURL,
httpCookie, httpCookie,
@ -68,16 +64,14 @@ func RecordStream(
if err != nil { if err != nil {
return nil, fmt.Errorf("variant-playlist: %w", err) return nil, fmt.Errorf("variant-playlist: %w", err)
} }
if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
finalPlaylistURL = strings.TrimSpace(finalPlaylistURL) return nil, errors.New("leere final video url")
if finalPlaylistURL == "" {
return nil, errors.New("leere final playlist url")
} }
return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie) return stream, nil
} }
playlist, err := loadFreshPlaylist() stream, err := loadFreshHLS()
if err != nil { if err != nil {
return err return err
} }
@ -89,7 +83,11 @@ func RecordStream(
} }
jobsMu.Lock() 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.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() 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 const maxPlaylistRefreshes = 12
refreshes := 0 refreshes := 0
for { for {
err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment) err = handleM3U8Mode(ctx, stream, outputPath, job, httpCookie, hc.userAgent, pageURL)
if err == nil { if err == nil {
break return nil
} }
if ctx.Err() != nil { if ctx.Err() != nil {
@ -165,14 +114,14 @@ func RecordStream(
shouldRefresh := shouldRefresh :=
strings.Contains(msg, "403") || strings.Contains(msg, "403") ||
strings.Contains(msg, "401") || strings.Contains(msg, "401") ||
strings.Contains(msg, "playlist nicht mehr erreichbar") || strings.Contains(msg, "invalid data found") ||
strings.Contains(msg, "fehlerhafte playlist") || strings.Contains(msg, "error opening input") ||
strings.Contains(msg, "keine neuen hls-segmente") ||
strings.Contains(msg, "stream-parsing") || strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "http ") strings.Contains(msg, "http ")
if !shouldRefresh { if !shouldRefresh {
return fmt.Errorf("watch segments: %w", err) return err
} }
refreshes++ refreshes++
@ -180,25 +129,14 @@ func RecordStream(
return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-time.After(1500 * time.Millisecond): case <-time.After(1500 * time.Millisecond):
} }
newPlaylist, rerr := loadFreshPlaylist() newStream, rerr := loadFreshHLS()
if rerr != nil { if rerr != nil {
fmt.Println("⚠️ [cb] refresh failed:",
"user=", username,
"try=", refreshes,
"err=", rerr,
)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -208,25 +146,20 @@ func RecordStream(
continue continue
} }
playlist = newPlaylist stream = newStream
if job != nil { if job != nil {
jobsMu.Lock() 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.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
} }
} }
if job != nil {
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
_ = publishJob(job.ID)
}
return nil
} }
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source) // ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
@ -255,212 +188,6 @@ func ParseStream(html string) (string, error) {
return rd.HLSSource, nil 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 // Cookie-Hilfsfunktion
func addCookiesFromString(req *http.Request, cookieStr string) { func addCookiesFromString(req *http.Request, cookieStr string) {
if cookieStr == "" { if cookieStr == "" {

View File

@ -20,16 +20,109 @@ import (
"github.com/grafov/m3u8" "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( func getWantedResolutionPlaylistWithHeaders(
ctx context.Context, ctx context.Context,
playlistURL string, playlistURL string,
httpCookie string, httpCookie string,
userAgent string, userAgent string,
referer string, referer string,
) (string, error) { ) (*selectedHLSStream, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil)
if err != nil { if err != nil {
return "", err return nil, err
} }
if strings.TrimSpace(userAgent) != "" { if strings.TrimSpace(userAgent) != "" {
@ -48,31 +141,54 @@ func getWantedResolutionPlaylistWithHeaders(
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 { 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 { 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) master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil { 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 bestURI string
var bestHeight int var bestHeight int
var bestFramerate float64 var bestFramerate float64
var bestAudioGroup string
for _, v := range master.Variants { for _, v := range master.Variants {
if v == nil || strings.TrimSpace(v.URI) == "" { if v == nil || strings.TrimSpace(v.URI) == "" {
@ -94,30 +210,102 @@ func getWantedResolutionPlaylistWithHeaders(
fr = v.FrameRate 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) bestURI = strings.TrimSpace(v.URI)
bestHeight = h bestHeight = h
bestFramerate = fr bestFramerate = fr
bestAudioGroup = audioGroup
} }
} }
if bestURI == "" { if bestURI == "" {
return "", errors.New("Master-Playlist ohne gültige Varianten") return nil, errors.New("Master-Playlist ohne gültige Varianten")
}
baseURL, err := url.Parse(playlistURL)
if err != nil {
return "", err
} }
refURL, err := url.Parse(bestURI) refURL, err := url.Parse(bestURI)
if err != nil { 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 &selectedHLSStream{
MasterURL: playlistURL,
return finalURL, nil VideoURL: videoURL,
AudioURL: audioURL,
HasSeparateAudio: hasSeparateAudio,
Height: bestHeight,
FPS: bestFramerate,
}, nil
} }
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
@ -148,16 +336,30 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
func handleM3U8Mode( func handleM3U8Mode(
ctx context.Context, ctx context.Context,
m3u8URL string, stream *selectedHLSStream,
outFile string, outFile string,
job *RecordJob, job *RecordJob,
httpCookie string, httpCookie string,
userAgent string, userAgent string,
referer string, referer string,
) error { ) 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") { 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) == "" { if strings.TrimSpace(outFile) == "" {
@ -177,32 +379,49 @@ func handleM3U8Mode(
ref := strings.TrimSpace(referer) ref := strings.TrimSpace(referer)
ck := strings.TrimSpace(httpCookie) ck := strings.TrimSpace(httpCookie)
if ua != "" { appendInputOptions := func(args []string) []string {
args = append(args, "-user_agent", ua) if ua != "" {
} args = append(args, "-user_agent", ua)
}
if ref != "" { if ref != "" {
args = append(args, "-referer", ref) args = append(args, "-referer", ref)
} }
if ck != "" {
if ck != "" { args = append(args, "-cookies", ck)
args = append(args, "-cookies", ck) }
} if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" {
args = append(args, "-headers", hdr)
if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" { }
args = append(args, "-headers", hdr) return args
} }
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile))) ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile)))
args = append(args, if audioURL != "" {
"-i", m3u8URL, args = appendInputOptions(args)
"-map", "0:v:0", args = append(args, "-i", videoURL)
"-map", "0:a:0?",
"-dn", args = appendInputOptions(args)
"-ignore_unknown", args = append(args, "-i", audioURL)
"-c", "copy",
) 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 { switch ext {
case ".ts": case ".ts":
@ -219,8 +438,6 @@ func handleM3U8Mode(
return fmt.Errorf("nicht unterstützte output-endung: %q", ext) return fmt.Errorf("nicht unterstützte output-endung: %q", ext)
} }
fmt.Println("🎬 ffmpeg hls args:", strings.Join(args, " "))
cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer var stderr bytes.Buffer

View File

@ -64,18 +64,23 @@ func RecordStreamMFC(
} }
// ✅ erst jetzt die Video URL holen (weil public) // ✅ erst jetzt die Video URL holen (weil public)
m3u8URL, err := mfc.GetVideoURL(false) stream, err := mfc.GetSelectedStream(false)
if err != nil { if err != nil {
return fmt.Errorf("mfc get video url: %w", err) 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") return fmt.Errorf("mfc: keine m3u8 URL gefunden")
} }
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
if job != nil { if job != nil {
previewURL := strings.TrimSpace(stream.VideoURL)
if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" {
previewURL = strings.TrimSpace(stream.MasterURL)
}
jobsMu.Lock() jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(m3u8URL) job.PreviewM3U8 = previewURL
job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen
job.PreviewUA = hc.userAgent job.PreviewUA = hc.userAgent
jobsMu.Unlock() jobsMu.Unlock()
@ -87,7 +92,7 @@ func RecordStreamMFC(
} }
// Aufnahme starten // 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 { type MyFreeCams struct {
@ -144,6 +149,24 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
return m.VideoURL, nil 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) { func (m *MyFreeCams) GetStatus() (Status, error) {
// 1) share-Seite prüfen (existiert/nicht existiert) // 1) share-Seite prüfen (existiert/nicht existiert)
shareURL := "https://share.myfreecams.com/" + m.Username 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) return fmt.Errorf("Stream ist nicht live (Status: %s)", st)
} }
m3u8URL, err := mfc.GetVideoURL(false) stream, err := mfc.GetSelectedStream(false)
if err != nil { if err != nil {
return err return err
} }
if m3u8URL == "" { if stream == nil || strings.TrimSpace(stream.VideoURL) == "" {
return errors.New("keine m3u8 URL gefunden") 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) { func getWantedResolutionPlaylist(playlistURL string) (string, error) {

View File

@ -16,5 +16,5 @@
"teaserPlayback": "all", "teaserPlayback": "all",
"teaserAudio": true, "teaserAudio": true,
"enableNotifications": 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", startRecordingFromRequest)
api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/status", recordStatus)
api.HandleFunc("/api/record/stop", recordStop) api.HandleFunc("/api/record/stop", recordStop)
api.HandleFunc("/api/record/stop-all", recordStopAll)
api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork) api.HandleFunc("/api/record/postwork/remove", recordRemoveQueuedPostwork)
api.HandleFunc("/api/preview", recordPreview) api.HandleFunc("/api/preview", recordPreview)
api.HandleFunc("/api/preview/live", recordPreviewLive) api.HandleFunc("/api/preview/live", recordPreviewLive)

View File

@ -826,16 +826,11 @@ export default function App() {
donePrefetchRef.current = null donePrefetchRef.current = null
const totalPages = getDoneTotalPages(doneCount, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
return return
} }
const res = await fetch( 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' : '' includeKeep ? '&includeKeep=1' : ''
}`, }`,
{ cache: 'no-store' as any } { cache: 'no-store' as any }
@ -846,13 +841,11 @@ export default function App() {
const items = Array.isArray(data?.items) const items = Array.isArray(data?.items)
? (data.items as RecordJob[]) ? (data.items as RecordJob[])
: Array.isArray(data) : []
? (data as RecordJob[])
: []
setDoneJobs(items) 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 const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (count !== doneCount) { if (count !== doneCount) {
@ -959,13 +952,9 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!authed) return if (!authed) return
void loadDoneCount() if (selectedTab !== 'finished') return
}, [authed, loadDoneCount])
useEffect(() => {
if (!authed) return
void loadDonePage(donePage, doneSort) void loadDonePage(donePage, doneSort)
}, [authed, donePage, doneSort, loadDonePage]) }, [authed, selectedTab, donePage, doneSort, loadDonePage])
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
const h = String(m?.host ?? '').toLowerCase() const h = String(m?.host ?? '').toLowerCase()
@ -1765,6 +1754,16 @@ export default function App() {
}, []) }, [])
const selectedTabRef = useRef(selectedTab) 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( const applyPendingRoomSnapshot = useCallback(
( (
@ -2102,8 +2101,8 @@ export default function App() {
}) })
if (alreadyRunning) return true if (alreadyRunning) return true
// ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status // ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { if (silent && provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
try { try {
const parsed = await apiJSON<ParsedModel>('/api/models/parse', { const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST', method: 'POST',
@ -2114,12 +2113,6 @@ export default function App() {
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) { 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 resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
let resolvedImageUrl: string | undefined let resolvedImageUrl: string | undefined
let resolvedChatRoomUrl: string | undefined let resolvedChatRoomUrl: string | undefined
@ -2168,27 +2161,20 @@ export default function App() {
if (resolvedShow === 'offline') { if (resolvedShow === 'offline') {
removePendingAutoStart(mkLower) removePendingAutoStart(mkLower)
setError('Model ist aktuell offline und wurde nicht zur Warteschlange hinzugefügt.')
return false return false
} }
// nur bei public unten normal starten // public => unten normal starten
} }
} catch { } catch {
if (silent) { const mkLower = providerKeyLowerFromUrl(norm)
const mkLower = providerKeyLowerFromUrl(norm) if (mkLower) {
if (mkLower) { queuePendingAutoStart(mkLower, norm, 'unknown')
queuePendingAutoStart(mkLower, norm, 'unknown') return true
return true
}
} }
} }
} }
if (busyRef.current) return false
setBusy(true)
busyRef.current = true
try { try {
const cookieString = Object.entries(currentCookies) const cookieString = Object.entries(currentCookies)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
@ -2221,9 +2207,6 @@ export default function App() {
if (!silent) setError(msg) if (!silent) setError(msg)
return false 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 // Cookies load/save
useEffect(() => { useEffect(() => {
@ -2412,9 +2395,12 @@ export default function App() {
fallbackTimer = window.setInterval(() => { fallbackTimer = window.setInterval(() => {
if (document.hidden) return if (document.hidden) return
void loadDoneCount()
void loadJobs() void loadJobs()
void loadTaskStateOnce() void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}, document.hidden ? 60000 : 5000) }, document.hidden ? 60000 : 5000)
} }
@ -2448,7 +2434,8 @@ export default function App() {
} }
const onDoneChanged = () => { const onDoneChanged = () => {
void loadDoneCount() if (selectedTabRef.current !== 'finished') return
void loadDonePage(donePageRef.current, doneSortRef.current)
} }
const onAutostart = (ev: MessageEvent) => { const onAutostart = (ev: MessageEvent) => {
@ -2547,8 +2534,11 @@ export default function App() {
const onVis = () => { const onVis = () => {
if (document.hidden) return if (document.hidden) return
void loadJobs() void loadJobs()
void loadDoneCount()
void loadTaskStateOnce() void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
} }
document.addEventListener('visibilitychange', onVis) 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) { async function removeQueuedPostworkJob(id: string) {
try { try {
await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, { await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, {
@ -2678,15 +2676,11 @@ export default function App() {
if (Number.isFinite(delta) && delta !== 0) { if (Number.isFinite(delta) && delta !== 0) {
setDoneCount((c) => Math.max(0, c + delta)) 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) window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [loadDoneCount]) }, [])
useEffect(() => { useEffect(() => {
const onNav = (ev: Event) => { const onNav = (ev: Event) => {
@ -2718,7 +2712,16 @@ export default function App() {
}, [playerJob, modelsByKey]) }, [playerJob, modelsByKey])
async function onStart() { async function onStart() {
return startUrl(sourceUrl) const ok = enqueueStart({
url: sourceUrl,
silent: false,
})
if (ok) {
setSourceUrl('')
}
return ok
} }
const handleAddToDownloads = useCallback( const handleAddToDownloads = useCallback(
@ -3552,6 +3555,7 @@ export default function App() {
}} }}
onOpenPlayer={openPlayer} onOpenPlayer={openPlayer}
onStopJob={stopJob} onStopJob={stopJob}
onStopAllJobs={stopAllJobs}
onRemoveQueuedPostworkJob={removeQueuedPostworkJob} onRemoveQueuedPostworkJob={removeQueuedPostworkJob}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike} onToggleLike={handleToggleLike}

View File

@ -42,6 +42,7 @@ type Props = {
roomStatusByModelKey?: Record<string, string> roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void> onStopJob: (id: string) => void | Promise<void>
onStopAllJobs?: () => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void> onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
blurPreviews?: boolean blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void> onToggleFavorite?: (job: RecordJob) => void | Promise<void>
@ -531,6 +532,7 @@ export default function Downloads({
onRefreshAutostartState, onRefreshAutostartState,
onOpenPlayer, onOpenPlayer,
onStopJob, onStopJob,
onStopAllJobs,
onToggleFavorite, onToggleFavorite,
onToggleLike, onToggleLike,
onToggleWatch, onToggleWatch,
@ -1325,11 +1327,16 @@ export default function Downloads({
setStopAllBusy(true) setStopAllBusy(true)
try { try {
markStopRequested(stoppableIds) 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 { } finally {
setStopAllBusy(false) setStopAllBusy(false)
} }
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob]) }, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob])
const rowClassName = useCallback((r: DownloadRow) => { const rowClassName = useCallback((r: DownloadRow) => {
if (r.kind === 'pending') { if (r.kind === 'pending') {

View File

@ -304,7 +304,6 @@ export default function FinishedDownloadsTableView({
}} }}
> >
<FinishedVideoPreview <FinishedVideoPreview
key={`table-preview-${k}-${isPreviewActive ? 'active' : 'idle'}`}
job={jobForDetails(j)} job={jobForDetails(j)}
getFileName={baseName} getFileName={baseName}
durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds} durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds}
@ -323,7 +322,7 @@ export default function FinishedDownloadsTableView({
animatedTrigger="always" animatedTrigger="always"
assetNonce={assetNonce} assetNonce={assetNonce}
forceActive={isPreviewActive} forceActive={isPreviewActive}
teaserPreloadEnabled teaserPreloadEnabled={false}
/> />
{/* Scrub-Frame Overlay */} {/* Scrub-Frame Overlay */}
@ -361,8 +360,7 @@ export default function FinishedDownloadsTableView({
const tags = parseTags(modelsByKey[modelKey]?.tags) const tags = parseTags(modelsByKey[modelKey]?.tags)
const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile) const postworkBadge = getPostworkSummaryForOutput(j.output, postworkByFile)
const showPostworkBadge = const showPostworkBadge = !!postworkBadge
!!postworkBadge && postworkBadge.state !== 'missing'
return ( return (
<div className="min-w-0"> <div className="min-w-0">
@ -371,9 +369,11 @@ export default function FinishedDownloadsTableView({
{model} {model}
</div> </div>
{showPostworkBadge && renderPostworkBadge {showPostworkBadge && renderPostworkBadge ? (
? renderPostworkBadge(postworkBadge) <div className="relative z-20 shrink-0">
: null} {renderPostworkBadge(postworkBadge)}
</div>
) : null}
</div> </div>
<div className="mt-0.5 min-w-0 text-xs text-gray-500 dark:text-gray-400"> <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} sort={sort}
onSortChange={handleSortChange} onSortChange={handleSortChange}
onRowClick={onOpenPlayer} 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) => ({ rowAttrs={(j) => ({
'data-finished-hover-key': keyFor(j), 'data-finished-hover-key': keyFor(j),
})} })}

View File

@ -6,6 +6,7 @@ import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy' import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
import { ForwardIcon } from '@heroicons/react/24/solid' import { ForwardIcon } from '@heroicons/react/24/solid'
import LoadingSpinner from './LoadingSpinner'
type Variant = 'thumb' | 'fill' type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover' type InlineVideoMode = false | true | 'always' | 'hover'
@ -311,9 +312,6 @@ export default function FinishedVideoPreview({
const [inView, setInView] = useState(false) const [inView, setInView] = useState(false)
const [nearView, setNearView] = 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 // Tick nur für frames-Mode
const [localTick, setLocalTick] = useState(0) const [localTick, setLocalTick] = useState(0)
@ -327,7 +325,6 @@ export default function FinishedVideoPreview({
const effectiveInView = forceActive || inView const effectiveInView = forceActive || inView
const effectiveNearView = forceActive || nearView const effectiveNearView = forceActive || nearView
const effectiveEverInView = forceActive || everInView
const inlineMode: 'never' | 'always' | 'hover' = const inlineMode: 'never' | 'always' | 'hover' =
inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never' inlineVideo === true || inlineVideo === 'always' ? 'always' : inlineVideo === 'hover' ? 'hover' : 'never'
@ -581,7 +578,6 @@ export default function FinishedVideoPreview({
(entries) => { (entries) => {
const hit = Boolean(entries[0]?.isIntersecting) const hit = Boolean(entries[0]?.isIntersecting)
setInView(hit) setInView(hit)
if (hit) setEverInView(true)
}, },
{ {
threshold: 0.01, threshold: 0.01,
@ -684,7 +680,6 @@ export default function FinishedVideoPreview({
const teaserActive = const teaserActive =
animated && animated &&
effectiveInView && effectiveInView &&
pageVisible &&
videoOk && videoOk &&
!showingInlineVideo && !showingInlineVideo &&
(animatedTrigger === 'always' || hovered) && (animatedTrigger === 'always' || hovered) &&
@ -705,11 +700,11 @@ export default function FinishedVideoPreview({
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating) // ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
const shouldLoadStill = const shouldLoadStill =
forceActive || alwaysLoadStill || effectiveInView || effectiveEverInView || (wantsHover && hovered) forceActive || alwaysLoadStill || effectiveInView || (wantsHover && hovered)
// ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben // ✅ Teaser/Clips "vorwärmen": schon in nearView erlauben
const shouldPreloadAnimatedAssets = const shouldPreloadAnimatedAssets =
forceActive || effectiveNearView || effectiveInView || effectiveEverInView || (wantsHover && hovered) forceActive || effectiveNearView || effectiveInView || (wantsHover && hovered)
useEffect(() => { useEffect(() => {
if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) { if (animatedMode !== 'teaser' || showingInlineVideo || !teaserActive) {
@ -815,7 +810,22 @@ export default function FinishedVideoPreview({
const showStillImage = const showStillImage =
shouldLoadStill && shouldLoadStill &&
!showingInlineVideo && !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) // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef = const progressVideoRef =
@ -932,10 +942,6 @@ export default function FinishedVideoPreview({
const active = teaserActive && animatedMode === 'teaser' const active = teaserActive && animatedMode === 'teaser'
if (!active) {
setTeaserPlaybackRate(1)
}
const clearRetryTimer = () => { const clearRetryTimer = () => {
if (teaserPlayRetryTimerRef.current != null) { if (teaserPlayRetryTimerRef.current != null) {
window.clearTimeout(teaserPlayRetryTimerRef.current) window.clearTimeout(teaserPlayRetryTimerRef.current)
@ -944,6 +950,17 @@ export default function FinishedVideoPreview({
} }
if (!active) { if (!active) {
setTeaserPlaybackRate(1)
clearRetryTimer()
try {
vv.pause()
} catch {}
return
}
// Tab hidden => nur pausieren, aber NICHT unmounten
if (!pageVisible) {
setTeaserPlaybackRate(1)
clearRetryTimer() clearRetryTimer()
try { try {
vv.pause() vv.pause()
@ -1086,7 +1103,7 @@ export default function FinishedVideoPreview({
if (typeof raf2 === 'number') cancelAnimationFrame(raf2) if (typeof raf2 === 'number') cancelAnimationFrame(raf2)
window.clearInterval(watchdog) window.clearInterval(watchdog)
} }
}, [teaserActive, animatedMode, teaserSrc, muted, pageVisible]) }, [teaserActive, animatedMode, teaserSrc, muted, pageVisible, teaserSpeedHold, setTeaserPlaybackRate])
useEffect(() => { useEffect(() => {
if (!activationNonce) return if (!activationNonce) return
@ -1321,6 +1338,22 @@ export default function FinishedVideoPreview({
<div className="absolute inset-0 bg-black/10 dark:bg-white/10" /> <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) */} {/* ✅ Inline Full Video (nur wenn sichtbar/aktiv) */}
{showingInlineVideo ? ( {showingInlineVideo ? (
<video <video
@ -1379,7 +1412,6 @@ export default function FinishedVideoPreview({
teaserMp4Ref.current = el teaserMp4Ref.current = el
bumpMountTickIfNew('teaser', el) bumpMountTickIfNew('teaser', el)
}} }}
key={`teaser-mp4-${previewId}-${forceActive ? 'force' : 'normal'}-${activationNonce}`}
src={teaserSrc} src={teaserSrc}
className={[ className={[
'absolute inset-0 w-full h-full object-cover pointer-events-none', 'absolute inset-0 w-full h-full object-cover pointer-events-none',
@ -1393,7 +1425,7 @@ export default function FinishedVideoPreview({
playsInline playsInline
autoPlay autoPlay
loop loop
preload={teaserReady ? 'auto' : 'metadata'} preload="auto"
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => { onLoadedData={() => {
setTeaserOk(true) setTeaserOk(true)

View File

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

View File

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