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))
|
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,11 +231,17 @@ 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 {
|
||||||
|
select {
|
||||||
|
case <-scanTicker.C:
|
||||||
if isAutostartPaused() {
|
if isAutostartPaused() {
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,15 +249,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if !s.UseChaturbateAPI {
|
if !s.UseChaturbateAPI {
|
||||||
queue = queue[:0]
|
queue = queue[:0]
|
||||||
queued = map[string]bool{}
|
queued = map[string]bool{}
|
||||||
time.Sleep(2 * time.Second)
|
lastCookieHdr = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieHdr := cookieHeaderFromSettings(s)
|
cookieHdr := cookieHeaderFromSettings(s)
|
||||||
if !hasChaturbateCookies(cookieHdr) {
|
if !hasChaturbateCookies(cookieHdr) {
|
||||||
time.Sleep(5 * time.Second)
|
lastCookieHdr = ""
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
lastCookieHdr = cookieHdr
|
||||||
|
|
||||||
// online snapshot aus cache
|
// online snapshot aus cache
|
||||||
cbMu.RLock()
|
cbMu.RLock()
|
||||||
@ -260,13 +292,43 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
// watched list aus DB
|
// 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{}
|
watchedByUser := map[string]WatchedModelLite{}
|
||||||
for _, m := range watched {
|
for _, m := range watched {
|
||||||
key := normUser(m.ModelKey)
|
if !m.Watching {
|
||||||
if key != "" && m.Watching {
|
continue
|
||||||
watchedByUser[key] = m
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// queue prune
|
||||||
@ -298,7 +360,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
// 1) watched + API sagt public => normal enqueue
|
// 1) watched + API sagt public => normal enqueue
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
for user, m := range watchedByUser {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if running[user] {
|
if running[user] {
|
||||||
@ -324,11 +390,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// 2) watched + NICHT in API => blind best-effort try wie MFC
|
// 2) watched + NICHT in API => blind best-effort try vormerken
|
||||||
// aber nur wenn:
|
|
||||||
// - nicht schon running
|
|
||||||
// - nicht schon queued
|
|
||||||
// - nicht kürzlich versucht
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
for user, m := range watchedByUser {
|
for user, m := range watchedByUser {
|
||||||
if seenInAPI[user] {
|
if seenInAPI[user] {
|
||||||
@ -349,65 +411,99 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// direkt "blind" starten, nicht erst in queue hängen,
|
queue = append(queue, autoStartItem{
|
||||||
// damit das Verhalten wie MFC ist
|
userKey: user,
|
||||||
if !lastStart.IsZero() && time.Since(lastStart) < startGap {
|
url: u,
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lastBlindTry[user] = time.Now()
|
|
||||||
|
|
||||||
job, err := startRecordingInternal(RecordRequest{
|
|
||||||
URL: u,
|
|
||||||
Cookie: cookieHdr,
|
|
||||||
Hidden: true,
|
|
||||||
})
|
})
|
||||||
if err != nil || job == nil {
|
queued[user] = true
|
||||||
if verboseLogs() {
|
|
||||||
fmt.Println("❌ [autostart] blind start failed:", u, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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()
|
lastTry[it.userKey] = time.Now()
|
||||||
|
|
||||||
job, err := startRecordingInternal(RecordRequest{
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
URL: it.url,
|
URL: it.url,
|
||||||
Cookie: cookieHdr,
|
Cookie: lastCookieHdr,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if verboseLogs() {
|
if verboseLogs() {
|
||||||
fmt.Println("❌ [autostart] start failed:", it.url, err)
|
fmt.Println("❌ [autostart] start failed:", it.url, err)
|
||||||
}
|
}
|
||||||
} else {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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(`
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 == "" {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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)
|
||||||
|
|
||||||
|
appendInputOptions := func(args []string) []string {
|
||||||
if ua != "" {
|
if ua != "" {
|
||||||
args = append(args, "-user_agent", 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 != "" {
|
if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" {
|
||||||
args = append(args, "-headers", hdr)
|
args = append(args, "-headers", hdr)
|
||||||
}
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile)))
|
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,
|
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:v:0",
|
||||||
"-map", "0:a:0?",
|
"-map", "0:a:0?",
|
||||||
"-dn",
|
"-dn",
|
||||||
"-ignore_unknown",
|
"-ignore_unknown",
|
||||||
"-c", "copy",
|
"-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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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=="
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,14 +2161,12 @@ 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')
|
||||||
@ -2183,11 +2174,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (busyRef.current) return false
|
|
||||||
setBusy(true)
|
|
||||||
busyRef.current = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookieString = Object.entries(currentCookies)
|
const cookieString = Object.entries(currentCookies)
|
||||||
@ -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}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
if (onStopAllJobs) {
|
||||||
|
await onStopAllJobs()
|
||||||
|
} else {
|
||||||
await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id))))
|
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') {
|
||||||
|
|||||||
@ -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),
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user