updated
This commit is contained in:
parent
fb4f196994
commit
144a8492d7
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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=="
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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),
|
||||
})}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user