updated
This commit is contained in:
parent
e4a139d45f
commit
32bd7c1e6e
@ -475,19 +475,13 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
|
|||||||
if cbModelStore != nil {
|
if cbModelStore != nil {
|
||||||
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
copiedRooms := append([]ChaturbateRoom(nil), rooms...)
|
||||||
|
|
||||||
// Statusfelder pflegen (online/offline, room_status, last_online_at, ...)
|
|
||||||
syncChaturbateRoomStateIntoModelStore(
|
syncChaturbateRoomStateIntoModelStore(
|
||||||
cbModelStore,
|
cbModelStore,
|
||||||
copiedRooms,
|
copiedRooms,
|
||||||
fetchedAtNow,
|
fetchedAtNow,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Vollständigen Snapshot pflegen, aber offline NICHT überschreiben
|
|
||||||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
|
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow)
|
||||||
|
|
||||||
if len(copiedRooms) > 0 {
|
|
||||||
go cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchedAtNow, nil
|
return fetchedAtNow, nil
|
||||||
@ -555,13 +549,8 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
|||||||
|
|
||||||
go func(roomsCopy []ChaturbateRoom, ts time.Time) {
|
go func(roomsCopy []ChaturbateRoom, ts time.Time) {
|
||||||
syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts)
|
syncChaturbateRoomStateIntoModelStore(cbModelStore, roomsCopy, ts)
|
||||||
|
|
||||||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts)
|
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, ts)
|
||||||
}(copiedRooms, fetchedAtNow)
|
}(copiedRooms, fetchedAtNow)
|
||||||
|
|
||||||
if len(copiedRooms) > 0 {
|
|
||||||
cbModelStore.FillMissingTagsFromChaturbateOnline(copiedRooms)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags übernehmen ist teuer -> nur selten + im Hintergrund
|
// Tags übernehmen ist teuer -> nur selten + im Hintergrund
|
||||||
@ -605,7 +594,7 @@ func cachedOnline(key string) ([]byte, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if time.Since(e.at) > 2*time.Second { // TTL
|
if time.Since(e.at) > 5*time.Second { // TTL
|
||||||
delete(onlineCache, key)
|
delete(onlineCache, key)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@ -635,6 +624,22 @@ type cbOnlineReq struct {
|
|||||||
Refresh bool `json:"refresh"`
|
Refresh bool `json:"refresh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cbOnlineOutRoom struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
CurrentShow string `json:"current_show"`
|
||||||
|
ChatRoomURL string `json:"chat_room_url"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cbOnlineResponse struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
Rooms []cbOnlineOutRoom `json:"rooms"`
|
||||||
|
}
|
||||||
|
|
||||||
func hashKey(parts ...string) string {
|
func hashKey(parts ...string) string {
|
||||||
h := sha1.New()
|
h := sha1.New()
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
@ -738,6 +743,46 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshRunningJobsHLSAsync(users []string, liteByUser map[string]ChaturbateOnlineRoomLite, cookieHeader string, reqUA string) {
|
||||||
|
if len(users) == 0 || liteByUser == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hlsMinInterval = 12 * time.Second
|
||||||
|
|
||||||
|
liteCopy := make(map[string]ChaturbateOnlineRoomLite, len(liteByUser))
|
||||||
|
for k, v := range liteByUser {
|
||||||
|
liteCopy[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(usersCopy []string, rooms map[string]ChaturbateOnlineRoomLite, cookie string, ua string) {
|
||||||
|
for _, u := range usersCopy {
|
||||||
|
rm, ok := rooms[u]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
||||||
|
if show == "" || show == "offline" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shouldRefreshHLS(u, hlsMinInterval) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
|
newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookie, ua)
|
||||||
|
cancel()
|
||||||
|
if err != nil || strings.TrimSpace(newHls) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRunningJobsHLS(u, newHls, cookie, ua)
|
||||||
|
}
|
||||||
|
}(append([]string(nil), users...), liteCopy, cookieHeader, reqUA)
|
||||||
|
}
|
||||||
|
|
||||||
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
||||||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
@ -926,13 +971,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
out := map[string]any{
|
out := cbOnlineResponse{
|
||||||
"enabled": false,
|
Enabled: false,
|
||||||
"fetchedAt": time.Time{},
|
FetchedAt: time.Time{},
|
||||||
"count": 0,
|
Count: 0,
|
||||||
"total": 0,
|
Total: 0,
|
||||||
"lastError": "",
|
LastError: "",
|
||||||
"rooms": []any{},
|
Rooms: []cbOnlineOutRoom{},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(out)
|
body, _ := json.Marshal(out)
|
||||||
setCachedOnline(cacheKey, body)
|
setCachedOnline(cacheKey, body)
|
||||||
@ -954,32 +999,31 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
needBootstrap := fetchedAt.IsZero()
|
needBootstrap := fetchedAt.IsZero()
|
||||||
|
|
||||||
// ✅ Bei explizitem refresh synchron aktualisieren,
|
// Bei explizitem refresh Snapshot-Aktualisierung asynchron anstoßen,
|
||||||
// damit die Response garantiert den neuesten Stand enthält.
|
// Response bleibt dabei schnell und liefert den letzten bekannten Stand.
|
||||||
if wantRefresh {
|
if wantRefresh {
|
||||||
cbRefreshMu.Lock()
|
cbRefreshMu.Lock()
|
||||||
if cbRefreshInFlight {
|
if !cbRefreshInFlight {
|
||||||
cbRefreshMu.Unlock()
|
|
||||||
} else {
|
|
||||||
cbRefreshInFlight = true
|
cbRefreshInFlight = true
|
||||||
cbRefreshMu.Unlock()
|
|
||||||
|
|
||||||
cbMu.Lock()
|
cbMu.Lock()
|
||||||
cb.LastAttempt = time.Now()
|
cb.LastAttempt = time.Now()
|
||||||
cbMu.Unlock()
|
cbMu.Unlock()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
go func() {
|
||||||
_, err := refreshChaturbateSnapshotNow(ctx)
|
defer func() {
|
||||||
cancel()
|
|
||||||
|
|
||||||
cbRefreshMu.Lock()
|
cbRefreshMu.Lock()
|
||||||
cbRefreshInFlight = false
|
cbRefreshInFlight = false
|
||||||
cbRefreshMu.Unlock()
|
cbRefreshMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
if err != nil {
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
// Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut
|
defer cancel()
|
||||||
}
|
|
||||||
|
_, _ = refreshChaturbateSnapshotNow(ctx)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
|
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
|
||||||
// ✅ Bootstrap darf weiterhin asynchron bleiben
|
// ✅ Bootstrap darf weiterhin asynchron bleiben
|
||||||
cbRefreshMu.Lock()
|
cbRefreshMu.Lock()
|
||||||
@ -1023,33 +1067,8 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
|
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
|
||||||
// und nur wenn User gerade online ist.
|
// und nur wenn User gerade online ist.
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
const hlsMinInterval = 12 * time.Second
|
|
||||||
|
|
||||||
if onlySpecificUsers && liteByUser != nil {
|
if onlySpecificUsers && liteByUser != nil {
|
||||||
for _, u := range users {
|
refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA)
|
||||||
rm, ok := liteByUser[u]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
|
||||||
if show == "offline" || show == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shouldRefreshHLS(u, hlsMinInterval) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
|
||||||
newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookieHeader, reqUA)
|
|
||||||
cancel()
|
|
||||||
if err != nil || strings.TrimSpace(newHls) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@ -1066,12 +1085,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Rooms bauen (LITE, O(Anzahl requested Users))
|
// Rooms bauen (LITE, O(Anzahl requested Users))
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
type outRoom struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
CurrentShow string `json:"current_show"`
|
|
||||||
ChatRoomURL string `json:"chat_room_url"`
|
|
||||||
ImageURL string `json:"image_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
matches := func(rm ChaturbateOnlineRoomLite) bool {
|
matches := func(rm ChaturbateOnlineRoomLite) bool {
|
||||||
if len(allowedShow) > 0 {
|
if len(allowedShow) > 0 {
|
||||||
@ -1131,7 +1144,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
outRooms := make([]outRoom, 0, len(users))
|
outRooms := make([]cbOnlineOutRoom, 0, len(users))
|
||||||
|
|
||||||
if onlySpecificUsers && liteByUser != nil {
|
if onlySpecificUsers && liteByUser != nil {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
@ -1142,7 +1155,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !matches(rm) {
|
if !matches(rm) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
outRooms = append(outRooms, outRoom{
|
outRooms = append(outRooms, cbOnlineOutRoom{
|
||||||
Username: rm.Username,
|
Username: rm.Username,
|
||||||
CurrentShow: rm.CurrentShow,
|
CurrentShow: rm.CurrentShow,
|
||||||
ChatRoomURL: rm.ChatRoomURL,
|
ChatRoomURL: rm.ChatRoomURL,
|
||||||
@ -1158,13 +1171,13 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
out := map[string]any{
|
out := cbOnlineResponse{
|
||||||
"enabled": true,
|
Enabled: true,
|
||||||
"fetchedAt": fetchedAt,
|
FetchedAt: fetchedAt,
|
||||||
"count": len(outRooms),
|
Count: len(outRooms),
|
||||||
"total": total,
|
Total: total,
|
||||||
"lastError": lastErr,
|
LastError: lastErr,
|
||||||
"rooms": outRooms,
|
Rooms: outRooms,
|
||||||
}
|
}
|
||||||
|
|
||||||
body, _ := json.Marshal(out)
|
body, _ := json.Marshal(out)
|
||||||
|
|||||||
@ -1326,14 +1326,18 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
|
|||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func latestPreviewSegment(previewDir string) (string, error) {
|
func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) {
|
||||||
segDir := previewSegmentsDir(previewDir)
|
segDir := previewSegmentsDir(previewDir)
|
||||||
entries, err := os.ReadDir(segDir)
|
entries, err := os.ReadDir(segDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var best string
|
if maxCount <= 0 {
|
||||||
|
maxCount = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
continue
|
continue
|
||||||
@ -1344,15 +1348,25 @@ func latestPreviewSegment(previewDir string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if best == "" || name > best {
|
names = append(names, e.Name())
|
||||||
best = e.Name()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if best == "" {
|
if len(names) == 0 {
|
||||||
return "", fmt.Errorf("kein Preview-Segment in %s", segDir)
|
return nil, fmt.Errorf("kein Preview-Segment in %s", segDir)
|
||||||
}
|
}
|
||||||
return filepath.Join(segDir, best), nil
|
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
if len(names) > maxCount {
|
||||||
|
names = names[len(names)-maxCount:]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
out = append(out, filepath.Join(segDir, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
||||||
@ -1361,19 +1375,51 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
|||||||
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
|
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
segPath, err := latestPreviewSegment(previewDir)
|
segPaths, err := latestPreviewSegments(previewDir, 4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(segPath))
|
ext := strings.ToLower(filepath.Ext(segPaths[len(segPaths)-1]))
|
||||||
|
|
||||||
// TS kann direkt gelesen werden
|
// Bei TS: ebenfalls lieber die letzten paar Segmente zusammenkleben,
|
||||||
|
// nicht nur das letzte einzelne Segment.
|
||||||
if ext == ".ts" {
|
if ext == ".ts" {
|
||||||
return segPath, func() {}, nil
|
tmp, err := os.CreateTemp(segDir, "preview_input_*.ts")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_ = tmp.Close()
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fMP4: init.mp4 + segment.m4s zu temp zusammensetzen
|
for _, segPath := range segPaths {
|
||||||
|
b, err := os.ReadFile(segPath)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tmp.Write(b); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpPath, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fMP4/CMAF: init.mp4 + die letzten N Segmente
|
||||||
initPath := filepath.Join(segDir, "init.mp4")
|
initPath := filepath.Join(segDir, "init.mp4")
|
||||||
if _, err := os.Stat(initPath); err != nil {
|
if _, err := os.Stat(initPath); err != nil {
|
||||||
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
|
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
|
||||||
@ -1384,11 +1430,6 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
|||||||
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
|
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
segBytes, err := os.ReadFile(segPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
|
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
@ -1404,10 +1445,22 @@ func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
|
|||||||
cleanup()
|
cleanup()
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, segPath := range segPaths {
|
||||||
|
segBytes, err := os.ReadFile(segPath)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
if len(segBytes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, err := tmp.Write(segBytes); err != nil {
|
if _, err := tmp.Write(segBytes); err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
cleanup()
|
cleanup()
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
@ -1666,25 +1719,52 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fallbackOnlyRaw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("fallbackOnly")))
|
||||||
|
fallbackOnly := fallbackOnlyRaw == "1" || fallbackOnlyRaw == "true" || fallbackOnlyRaw == "yes"
|
||||||
|
|
||||||
|
if fallbackOnly {
|
||||||
|
jobsMu.RLock()
|
||||||
|
job, ok := jobs[id]
|
||||||
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
if serveFallbackPreviewImage(w, r, job, job.Output) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
servePreviewStatusSVG(w, "Preview", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assetID := stripHotPrefix(strings.TrimSpace(id))
|
||||||
|
if assetID != "" {
|
||||||
|
if outPath, err := findFinishedFileByID(assetID); err == nil {
|
||||||
|
if serveFallbackPreviewImage(w, r, nil, outPath) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
servePreviewStatusSVG(w, "Preview", http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// file serving
|
// file serving
|
||||||
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
|
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
|
||||||
low := strings.ToLower(strings.TrimSpace(file))
|
low := strings.ToLower(strings.TrimSpace(file))
|
||||||
|
|
||||||
// ✅ preview.jpg weiterhin hier behandeln
|
|
||||||
if low == "preview.jpg" {
|
if low == "preview.jpg" {
|
||||||
servePreviewJPGAlias(w, r, id)
|
servePreviewJPGAlias(w, r, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go
|
|
||||||
recordPreviewFile(w, r)
|
recordPreviewFile(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// JPG preview (running jobs have live thumb behavior)
|
// JPG preview (running jobs have live thumb behavior)
|
||||||
jobsMu.Lock()
|
jobsMu.RLock()
|
||||||
job, ok := jobs[id]
|
job, ok := jobs[id]
|
||||||
jobsMu.Unlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
if job.Status == JobRunning {
|
if job.Status == JobRunning {
|
||||||
@ -1741,9 +1821,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.RLock()
|
||||||
state := strings.TrimSpace(job.PreviewState)
|
state := strings.TrimSpace(job.PreviewState)
|
||||||
jobsMu.Unlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
if state == "private" {
|
if state == "private" {
|
||||||
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
|
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
|
||||||
@ -1763,11 +1843,11 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
||||||
jobsMu.Lock()
|
jobsMu.RLock()
|
||||||
status := job.Status
|
status := job.Status
|
||||||
previewDir := job.PreviewDir
|
previewDir := job.PreviewDir
|
||||||
out := job.Output
|
out := job.Output
|
||||||
jobsMu.Unlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
if status != JobRunning {
|
if status != JobRunning {
|
||||||
return
|
return
|
||||||
@ -1780,7 +1860,8 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
|
if st, err := os.Stat(thumbPath); err == nil && st.Size() > 0 {
|
||||||
if time.Since(st.ModTime()) < 10*time.Second {
|
// etwas Puffer, damit die 10s-Loop nicht an exakten Grenzwerten hängen bleibt
|
||||||
|
if time.Since(st.ModTime()) < 9*time.Second {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1827,9 +1908,9 @@ func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
jobsMu.Lock()
|
jobsMu.RLock()
|
||||||
st := job.Status
|
st := job.Status
|
||||||
jobsMu.Unlock()
|
jobsMu.RUnlock()
|
||||||
if st != JobRunning {
|
if st != JobRunning {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
|
||||||
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
|
||||||
"recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
|
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records",
|
||||||
"doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
|
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done",
|
||||||
"ffmpegPath": "",
|
"ffmpegPath": "",
|
||||||
"autoAddToDownloadList": true,
|
"autoAddToDownloadList": true,
|
||||||
"autoStartAddedDownloads": true,
|
"autoStartAddedDownloads": true,
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import CategoriesTab from './components/ui/CategoriesTab'
|
|||||||
import LoginPage from './components/ui/LoginPage'
|
import LoginPage from './components/ui/LoginPage'
|
||||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||||
import TextInput from './components/ui/TextInput'
|
import TextInput from './components/ui/TextInput'
|
||||||
|
import LastUpdatedText from './components/ui/LastUpdatedText'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -477,6 +478,36 @@ const isPostworkJobForCount = (job: RecordJob): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
|
||||||
|
const incomingId = String((incoming as any)?.id ?? '').trim()
|
||||||
|
if (!incomingId) return list
|
||||||
|
|
||||||
|
const byId = new Map<string, RecordJob>()
|
||||||
|
|
||||||
|
for (const j of list) {
|
||||||
|
const id = String((j as any)?.id ?? '').trim()
|
||||||
|
if (!id) continue
|
||||||
|
byId.set(id, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
byId.set(incomingId, {
|
||||||
|
...(byId.get(incomingId) ?? ({} as RecordJob)),
|
||||||
|
...incoming,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(byId.values()).sort((a, b) => {
|
||||||
|
const aMs =
|
||||||
|
Number((a as any)?.startedAtMs ?? 0) ||
|
||||||
|
(a?.startedAt ? Date.parse(a.startedAt) || 0 : 0)
|
||||||
|
|
||||||
|
const bMs =
|
||||||
|
Number((b as any)?.startedAtMs ?? 0) ||
|
||||||
|
(b?.startedAt ? Date.parse(b.startedAt) || 0 : 0)
|
||||||
|
|
||||||
|
return bMs - aMs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
@ -544,6 +575,9 @@ export default function App() {
|
|||||||
const modelEventNamesRef = useRef<Set<string>>(new Set())
|
const modelEventNamesRef = useRef<Set<string>>(new Set())
|
||||||
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
|
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
|
||||||
|
|
||||||
|
const lastSeenAtByJobIdRef = useRef<Record<string, number>>({})
|
||||||
|
const MISSING_JOB_GRACE_MS = 20_000
|
||||||
|
|
||||||
|
|
||||||
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
|
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
|
||||||
const [sourceUrl, setSourceUrl] = useState('')
|
const [sourceUrl, setSourceUrl] = useState('')
|
||||||
@ -556,7 +590,6 @@ export default function App() {
|
|||||||
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
|
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
|
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
|
||||||
const [nowMs, setNowMs] = useState<number>(() => Date.now())
|
|
||||||
|
|
||||||
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||||||
|
|
||||||
@ -852,15 +885,69 @@ export default function App() {
|
|||||||
|
|
||||||
const data = await res.json().catch(() => null)
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
// akzeptiere: Array oder { items: [] }
|
const serverItems = Array.isArray(data)
|
||||||
const items = Array.isArray(data)
|
|
||||||
? (data as RecordJob[])
|
? (data as RecordJob[])
|
||||||
: Array.isArray(data?.items)
|
: Array.isArray(data?.items)
|
||||||
? (data.items as RecordJob[])
|
? (data.items as RecordJob[])
|
||||||
: []
|
: []
|
||||||
|
|
||||||
setJobs(items)
|
const now = Date.now()
|
||||||
jobsRef.current = items
|
|
||||||
|
for (const j of serverItems) {
|
||||||
|
if (j?.id) {
|
||||||
|
lastSeenAtByJobIdRef.current[j.id] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setJobs((prev) => {
|
||||||
|
const byId = new Map<string, RecordJob>()
|
||||||
|
|
||||||
|
// 1) Server gewinnt immer
|
||||||
|
for (const j of serverItems) {
|
||||||
|
if (!j?.id) continue
|
||||||
|
byId.set(j.id, j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Lokale aktive Jobs kurz weiter behalten, wenn sie serverseitig
|
||||||
|
// vorübergehend fehlen
|
||||||
|
for (const j of prev) {
|
||||||
|
if (!j?.id) continue
|
||||||
|
if (byId.has(j.id)) continue
|
||||||
|
|
||||||
|
const status = String(j.status ?? '').trim().toLowerCase()
|
||||||
|
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
||||||
|
const isTerminal =
|
||||||
|
status === 'stopped' ||
|
||||||
|
status === 'finished' ||
|
||||||
|
status === 'failed' ||
|
||||||
|
status === 'done' ||
|
||||||
|
status === 'completed' ||
|
||||||
|
status === 'canceled' ||
|
||||||
|
status === 'cancelled'
|
||||||
|
|
||||||
|
const isActive =
|
||||||
|
!j.endedAt &&
|
||||||
|
!isTerminal &&
|
||||||
|
(status === 'running' || phase === 'recording' || phase === '')
|
||||||
|
|
||||||
|
const lastSeen = lastSeenAtByJobIdRef.current[j.id] ?? 0
|
||||||
|
const withinGrace = now - lastSeen < MISSING_JOB_GRACE_MS
|
||||||
|
|
||||||
|
if (isActive || withinGrace) {
|
||||||
|
byId.set(j.id, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = Array.from(byId.values()).sort((a, b) => {
|
||||||
|
const ta = new Date(a.startedAt || 0).getTime()
|
||||||
|
const tb = new Date(b.startedAt || 0).getTime()
|
||||||
|
return tb - ta
|
||||||
|
})
|
||||||
|
|
||||||
|
jobsRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(Date.now())
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@ -909,28 +996,10 @@ export default function App() {
|
|||||||
const jobId = String(msg?.jobId ?? '').trim()
|
const jobId = String(msg?.jobId ?? '').trim()
|
||||||
if (!jobId) return
|
if (!jobId) return
|
||||||
|
|
||||||
if (msg.type === 'job_remove') {
|
const now = Date.now()
|
||||||
setJobs((prev) => {
|
|
||||||
const next = prev.filter((j) => String((j as any).id ?? '') !== jobId)
|
|
||||||
jobsRef.current = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
setPlayerJob((prev) =>
|
const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => {
|
||||||
prev && String((prev as any).id ?? '') === jobId ? null : prev
|
return {
|
||||||
)
|
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type !== 'job_upsert') return
|
|
||||||
|
|
||||||
setJobs((prev) => {
|
|
||||||
const idx = prev.findIndex((j) => String((j as any).id ?? '') === jobId)
|
|
||||||
const prevJob = idx >= 0 ? prev[idx] : null
|
|
||||||
|
|
||||||
const patch: RecordJob = {
|
|
||||||
...(prevJob ?? ({} as RecordJob)),
|
...(prevJob ?? ({} as RecordJob)),
|
||||||
id: jobId,
|
id: jobId,
|
||||||
status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
|
status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
|
||||||
@ -959,56 +1028,60 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
: (prevJob as any)?.postWork,
|
: (prevJob as any)?.postWork,
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
let next: RecordJob[]
|
|
||||||
if (idx >= 0) {
|
|
||||||
next = [...prev]
|
|
||||||
next[idx] = patch
|
|
||||||
} else {
|
|
||||||
next = [patch, ...prev]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'job_remove') {
|
||||||
|
delete lastSeenAtByJobIdRef.current[jobId]
|
||||||
|
|
||||||
|
setJobs((prev) => {
|
||||||
|
const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId)
|
||||||
|
jobsRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setPlayerJob((prev) =>
|
||||||
|
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
|
||||||
|
)
|
||||||
|
|
||||||
|
setLastHeaderUpdateAtMs(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type !== 'job_upsert') return
|
||||||
|
|
||||||
|
lastSeenAtByJobIdRef.current[jobId] = now
|
||||||
|
|
||||||
|
setJobs((prev) => {
|
||||||
|
const sameId = prev.filter((j) => String((j as any).id ?? '').trim() === jobId)
|
||||||
|
const prevJob = sameId.length > 0 ? sameId[0] : null
|
||||||
|
const merged = mergeJob(prevJob)
|
||||||
|
|
||||||
|
const next = [
|
||||||
|
merged,
|
||||||
|
...prev.filter((j) => String((j as any).id ?? '').trim() !== jobId),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const aMs =
|
||||||
|
Number((a as any)?.startedAtMs ?? 0) ||
|
||||||
|
(a?.startedAt ? Date.parse(a.startedAt) || 0 : 0)
|
||||||
|
|
||||||
|
const bMs =
|
||||||
|
Number((b as any)?.startedAtMs ?? 0) ||
|
||||||
|
(b?.startedAt ? Date.parse(b.startedAt) || 0 : 0)
|
||||||
|
|
||||||
|
return bMs - aMs
|
||||||
|
})
|
||||||
|
|
||||||
jobsRef.current = next
|
jobsRef.current = next
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setPlayerJob((prev) => {
|
setPlayerJob((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return String((prev as any).id ?? '') === jobId
|
if (String((prev as any).id ?? '').trim() !== jobId) return prev
|
||||||
? ({
|
return mergeJob(prev)
|
||||||
...prev,
|
|
||||||
...{
|
|
||||||
id: jobId,
|
|
||||||
status: (msg.status as any) ?? (prev as any).status,
|
|
||||||
sourceUrl: msg.sourceUrl ?? (prev as any).sourceUrl,
|
|
||||||
output: msg.output ?? prev.output,
|
|
||||||
startedAt: msg.startedAt ?? prev.startedAt,
|
|
||||||
endedAt: msg.endedAt ?? prev.endedAt,
|
|
||||||
phase: msg.phase ?? (prev as any).phase,
|
|
||||||
progress: msg.progress ?? (prev as any).progress,
|
|
||||||
startedAtMs: msg.startedAtMs ?? (prev as any).startedAtMs,
|
|
||||||
endedAtMs: msg.endedAtMs ?? (prev as any).endedAtMs,
|
|
||||||
sizeBytes: msg.sizeBytes ?? (prev as any).sizeBytes,
|
|
||||||
durationSeconds: msg.durationSeconds ?? (prev as any).durationSeconds,
|
|
||||||
previewState: msg.previewState ?? (prev as any).previewState,
|
|
||||||
roomStatus: msg.roomStatus ?? (prev as any).roomStatus,
|
|
||||||
isOnline: msg.isOnline ?? (prev as any).isOnline,
|
|
||||||
modelImageUrl: msg.modelImageUrl ?? (prev as any).modelImageUrl,
|
|
||||||
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prev as any).modelChatRoomUrl,
|
|
||||||
|
|
||||||
postWorkKey: msg.postWorkKey ?? (prev as any).postWorkKey,
|
|
||||||
postWork: msg.postWork
|
|
||||||
? {
|
|
||||||
...((prev as any).postWork ?? {}),
|
|
||||||
...msg.postWork,
|
|
||||||
}
|
|
||||||
: (prev as any).postWork,
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
: prev
|
|
||||||
})
|
})
|
||||||
|
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
setLastHeaderUpdateAtMs(now)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ensureModelEventListener = useCallback((name: string) => {
|
const ensureModelEventListener = useCallback((name: string) => {
|
||||||
@ -1050,25 +1123,6 @@ export default function App() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAgoDE = (diffMs: number) => {
|
|
||||||
const s = Math.max(0, Math.floor(diffMs / 1000))
|
|
||||||
if (s < 2) return 'gerade eben'
|
|
||||||
if (s < 60) return `vor ${s} Sekunden`
|
|
||||||
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
if (m === 1) return 'vor 1 Minute'
|
|
||||||
if (m < 60) return `vor ${m} Minuten`
|
|
||||||
|
|
||||||
const h = Math.floor(m / 60)
|
|
||||||
if (h === 1) return 'vor 1 Stunde'
|
|
||||||
return `vor ${h} Stunden`
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerUpdatedText = useMemo(() => {
|
|
||||||
const diff = nowMs - lastHeaderUpdateAtMs
|
|
||||||
return `(zuletzt aktualisiert: ${formatAgoDE(diff)})`
|
|
||||||
}, [nowMs, lastHeaderUpdateAtMs])
|
|
||||||
|
|
||||||
const buildModelsByKey = useCallback((list: StoredModel[]) => {
|
const buildModelsByKey = useCallback((list: StoredModel[]) => {
|
||||||
const map: Record<string, StoredModel> = {}
|
const map: Record<string, StoredModel> = {}
|
||||||
|
|
||||||
@ -1285,11 +1339,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
|
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
|
|
||||||
return () => window.clearInterval(t)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOpen = (ev: Event) => {
|
const onOpen = (ev: Event) => {
|
||||||
const e = ev as CustomEvent<{ modelKey?: string }>
|
const e = ev as CustomEvent<{ modelKey?: string }>
|
||||||
@ -1516,8 +1565,11 @@ export default function App() {
|
|||||||
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
|
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
|
||||||
|
|
||||||
// UI sofort aktualisieren (optional)
|
// UI sofort aktualisieren (optional)
|
||||||
setJobs((prev) => [created, ...prev])
|
setJobs((prev) => {
|
||||||
jobsRef.current = [created, ...jobsRef.current]
|
const next = upsertJobById(prev, created)
|
||||||
|
jobsRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -2104,8 +2156,11 @@ export default function App() {
|
|||||||
// und kurz danach kommt der Job nochmal über SSE/polling rein.
|
// und kurz danach kommt der Job nochmal über SSE/polling rein.
|
||||||
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
|
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
|
||||||
|
|
||||||
setJobs((prev) => [created, ...prev])
|
setJobs((prev) => {
|
||||||
jobsRef.current = [created, ...jobsRef.current]
|
const next = upsertJobById(prev, created)
|
||||||
|
jobsRef.current = next
|
||||||
|
return next
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = e?.message ?? String(e)
|
const msg = e?.message ?? String(e)
|
||||||
@ -3358,14 +3413,14 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
|
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
{headerUpdatedText}
|
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
|
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
|
||||||
<div className="sm:hidden mt-1 w-full">
|
<div className="sm:hidden mt-1 w-full">
|
||||||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
{headerUpdatedText}
|
<LastUpdatedText lastAt={lastHeaderUpdateAtMs} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-stretch gap-2">
|
<div className="mt-2 flex items-stretch gap-2">
|
||||||
|
|||||||
@ -179,17 +179,6 @@ function effectiveRoomStatusOfJob(
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewAlignEndAtOfJob(job: RecordJob): string | null {
|
|
||||||
const postworkState = getEffectivePostworkState(job)
|
|
||||||
|
|
||||||
// Während Nachbearbeitung Preview nicht "abschalten"
|
|
||||||
if (postworkState === 'running' || postworkState === 'queued') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return job.endedAt ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomStatusTone = (status: string): string => {
|
const roomStatusTone = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Public':
|
case 'Public':
|
||||||
@ -220,35 +209,6 @@ const toMs = (v: unknown): number => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const absUrlMaybe = (u?: string | null): string => {
|
|
||||||
const s = String(u ?? '').trim()
|
|
||||||
if (!s) return ''
|
|
||||||
if (/^https?:\/\//i.test(s)) return s
|
|
||||||
if (s.startsWith('/')) return s
|
|
||||||
return `/${s}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
|
|
||||||
const j = job as any
|
|
||||||
|
|
||||||
const direct = [
|
|
||||||
j.thumbsJPGUrl,
|
|
||||||
j.thumbsUrl,
|
|
||||||
j.previewThumbsUrl,
|
|
||||||
j.thumbnailSheetUrl,
|
|
||||||
]
|
|
||||||
|
|
||||||
const base = [
|
|
||||||
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
]
|
|
||||||
|
|
||||||
return [...direct, ...base]
|
|
||||||
.map((x) => absUrlMaybe(String(x ?? '')))
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addedAtMsOf = (r: DownloadRow): number => {
|
const addedAtMsOf = (r: DownloadRow): number => {
|
||||||
if (r.kind === 'job') {
|
if (r.kind === 'job') {
|
||||||
const j = r.job as any
|
const j = r.job as any
|
||||||
@ -959,6 +919,7 @@ export default function Downloads({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
|
live={true}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={effectiveRoomStatusOfJob(
|
roomStatus={effectiveRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
@ -966,13 +927,7 @@ export default function Downloads({
|
|||||||
modelsByKey,
|
modelsByKey,
|
||||||
growingByJobId
|
growingByJobId
|
||||||
)}
|
)}
|
||||||
alignStartAt={j.startedAt}
|
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
|
||||||
alignEndAt={previewAlignEndAtOfJob(j)}
|
|
||||||
alignEveryMs={10_000}
|
|
||||||
fastRetryMs={1000}
|
|
||||||
fastRetryMax={25}
|
|
||||||
fastRetryWindowMs={60_000}
|
|
||||||
thumbsCandidates={jobThumbsJPGCandidates(j)}
|
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</LazyMountWhenVisible>
|
</LazyMountWhenVisible>
|
||||||
@ -1278,11 +1233,19 @@ export default function Downloads({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
const list = jobs
|
const list = jobs
|
||||||
.filter((j) => {
|
.filter((j) => {
|
||||||
if (isPostworkJob(j)) return false
|
if (isPostworkJob(j)) return false
|
||||||
if (isTerminalStatus((j as any)?.status)) return false
|
if (isTerminalStatus((j as any)?.status)) return false
|
||||||
if (Boolean((j as any)?.endedAt)) return false
|
if (Boolean((j as any)?.endedAt)) return false
|
||||||
|
|
||||||
|
const id = String((j as any)?.id ?? '').trim()
|
||||||
|
if (!id) return false
|
||||||
|
if (seen.has(id)) return false
|
||||||
|
|
||||||
|
seen.add(id)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map((job) => ({ kind: 'job', job }) as const)
|
.map((job) => ({ kind: 'job', job }) as const)
|
||||||
|
|||||||
@ -200,16 +200,6 @@ function previewRoomStatusOfJob(
|
|||||||
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
|
return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewAlignEndAtOfJob(job: RecordJob): string | null {
|
|
||||||
const postworkState = getEffectivePostworkState(job)
|
|
||||||
|
|
||||||
if (postworkState === 'running' || postworkState === 'queued') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return job.endedAt ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const roomStatusTone = (status: string): string => {
|
const roomStatusTone = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Public':
|
case 'Public':
|
||||||
@ -240,35 +230,6 @@ const toMs = (v: unknown): number => {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const absUrlMaybe = (u?: string | null): string => {
|
|
||||||
const s = String(u ?? '').trim()
|
|
||||||
if (!s) return ''
|
|
||||||
if (/^https?:\/\//i.test(s)) return s
|
|
||||||
if (s.startsWith('/')) return s
|
|
||||||
return `/${s}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
|
|
||||||
const j = job as any
|
|
||||||
|
|
||||||
const direct = [
|
|
||||||
j.thumbsJPGUrl,
|
|
||||||
j.thumbsUrl,
|
|
||||||
j.previewThumbsUrl,
|
|
||||||
j.thumbnailSheetUrl,
|
|
||||||
]
|
|
||||||
|
|
||||||
const base = [
|
|
||||||
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
|
|
||||||
]
|
|
||||||
|
|
||||||
return [...direct, ...base]
|
|
||||||
.map((x) => absUrlMaybe(String(x ?? '')))
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (ms: number): string => {
|
const formatDuration = (ms: number): string => {
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||||
const total = Math.floor(ms / 1000)
|
const total = Math.floor(ms / 1000)
|
||||||
@ -711,6 +672,7 @@ export default function DownloadsCardRow({
|
|||||||
>
|
>
|
||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
|
live={true}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={previewRoomStatusOfJob(
|
roomStatus={previewRoomStatusOfJob(
|
||||||
j,
|
j,
|
||||||
@ -718,13 +680,7 @@ export default function DownloadsCardRow({
|
|||||||
modelsByKey,
|
modelsByKey,
|
||||||
growingByJobId
|
growingByJobId
|
||||||
)}
|
)}
|
||||||
alignStartAt={j.startedAt}
|
fallbackSrc={`/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1`}
|
||||||
alignEndAt={previewAlignEndAtOfJob(j)}
|
|
||||||
alignEveryMs={10_000}
|
|
||||||
fastRetryMs={1000}
|
|
||||||
fastRetryMax={25}
|
|
||||||
fastRetryWindowMs={60_000}
|
|
||||||
thumbsCandidates={jobThumbsJPGCandidates(j)}
|
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</LazyMountWhenVisible>
|
</LazyMountWhenVisible>
|
||||||
|
|||||||
@ -67,8 +67,6 @@ export type FinishedVideoPreviewProps = {
|
|||||||
/** Popover-Video muted? (Default: true) */
|
/** Popover-Video muted? (Default: true) */
|
||||||
popoverMuted?: boolean
|
popoverMuted?: boolean
|
||||||
|
|
||||||
noGenerateTeaser?: boolean
|
|
||||||
|
|
||||||
/** Still-Preview (Bild) unabhängig vom inView-Gating laden */
|
/** Still-Preview (Bild) unabhängig vom inView-Gating laden */
|
||||||
alwaysLoadStill?: boolean
|
alwaysLoadStill?: boolean
|
||||||
|
|
||||||
@ -124,7 +122,6 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
muted = DEFAULT_INLINE_MUTED,
|
muted = DEFAULT_INLINE_MUTED,
|
||||||
popoverMuted = DEFAULT_INLINE_MUTED,
|
popoverMuted = DEFAULT_INLINE_MUTED,
|
||||||
noGenerateTeaser,
|
|
||||||
alwaysLoadStill = false,
|
alwaysLoadStill = false,
|
||||||
teaserPreloadEnabled = false,
|
teaserPreloadEnabled = false,
|
||||||
teaserPreloadRootMargin = '700px 0px',
|
teaserPreloadRootMargin = '700px 0px',
|
||||||
@ -557,7 +554,7 @@ export default function FinishedVideoPreview({
|
|||||||
emittedResolutionRef.current = ''
|
emittedResolutionRef.current = ''
|
||||||
teaserSoundForcedRef.current = false
|
teaserSoundForcedRef.current = false
|
||||||
setMetaLoaded(false)
|
setMetaLoaded(false)
|
||||||
}, [previewId, assetNonce, noGenerateTeaser])
|
}, [previewId, assetNonce])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onRelease = (ev: any) => {
|
const onRelease = (ev: any) => {
|
||||||
@ -673,9 +670,8 @@ export default function FinishedVideoPreview({
|
|||||||
|
|
||||||
const teaserSrc = useMemo(() => {
|
const teaserSrc = useMemo(() => {
|
||||||
if (!previewId) return ''
|
if (!previewId) return ''
|
||||||
const noGen = noGenerateTeaser ? '&noGenerate=1' : ''
|
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}`
|
||||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
|
}, [previewId, v])
|
||||||
}, [previewId, v, noGenerateTeaser])
|
|
||||||
|
|
||||||
// --- Inline Video sichtbar?
|
// --- Inline Video sichtbar?
|
||||||
const showingInlineVideo =
|
const showingInlineVideo =
|
||||||
@ -811,7 +807,6 @@ export default function FinishedVideoPreview({
|
|||||||
const teaserCanPrewarm =
|
const teaserCanPrewarm =
|
||||||
animated &&
|
animated &&
|
||||||
animatedMode === 'teaser' &&
|
animatedMode === 'teaser' &&
|
||||||
teaserOk &&
|
|
||||||
Boolean(teaserSrc) &&
|
Boolean(teaserSrc) &&
|
||||||
!showingInlineVideo &&
|
!showingInlineVideo &&
|
||||||
shouldPreloadAnimatedAssets &&
|
shouldPreloadAnimatedAssets &&
|
||||||
@ -1361,9 +1356,15 @@ export default function FinishedVideoPreview({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload="auto"
|
preload="metadata"
|
||||||
onLoadedData={() => setTeaserReady(true)}
|
onLoadedData={() => {
|
||||||
onCanPlay={() => setTeaserReady(true)}
|
setTeaserOk(true)
|
||||||
|
setTeaserReady(true)
|
||||||
|
}}
|
||||||
|
onCanPlay={() => {
|
||||||
|
setTeaserOk(true)
|
||||||
|
setTeaserReady(true)
|
||||||
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setTeaserOk(false)
|
setTeaserOk(false)
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
@ -1394,6 +1395,14 @@ export default function FinishedVideoPreview({
|
|||||||
loop
|
loop
|
||||||
preload={teaserReady ? 'auto' : 'metadata'}
|
preload={teaserReady ? 'auto' : 'metadata'}
|
||||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||||
|
onLoadedData={() => {
|
||||||
|
setTeaserOk(true)
|
||||||
|
setTeaserReady(true)
|
||||||
|
}}
|
||||||
|
onCanPlay={() => {
|
||||||
|
setTeaserOk(true)
|
||||||
|
setTeaserReady(true)
|
||||||
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setTeaserOk(false)
|
setTeaserOk(false)
|
||||||
setTeaserReady(false)
|
setTeaserReady(false)
|
||||||
|
|||||||
30
frontend/src/components/ui/LastUpdatedText.tsx
Normal file
30
frontend/src/components/ui/LastUpdatedText.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
function formatAgoDE(diffMs: number) {
|
||||||
|
const s = Math.max(0, Math.floor(diffMs / 1000))
|
||||||
|
if (s < 2) return 'gerade eben'
|
||||||
|
if (s < 60) return `vor ${s} Sekunden`
|
||||||
|
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
if (m === 1) return 'vor 1 Minute'
|
||||||
|
if (m < 60) return `vor ${m} Minuten`
|
||||||
|
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h === 1) return 'vor 1 Stunde'
|
||||||
|
return `vor ${h} Stunden`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LastUpdatedText({ lastAt }: { lastAt: number }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const text = useMemo(() => {
|
||||||
|
return `(zuletzt aktualisiert: ${formatAgoDE(now - lastAt)})`
|
||||||
|
}, [now, lastAt])
|
||||||
|
|
||||||
|
return <>{text}</>
|
||||||
|
}
|
||||||
@ -12,119 +12,68 @@ import {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
jobId: string
|
jobId: string
|
||||||
|
live?: boolean
|
||||||
thumbTick?: number
|
thumbTick?: number
|
||||||
autoTickMs?: number
|
autoTickMs?: number
|
||||||
blur?: boolean
|
blur?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
fit?: 'cover' | 'contain'
|
fit?: 'cover' | 'contain'
|
||||||
roomStatus?: string
|
roomStatus?: string
|
||||||
|
fallbackSrc?: string
|
||||||
alignStartAt?: string | number | Date
|
|
||||||
alignEndAt?: string | number | Date | null
|
|
||||||
alignEveryMs?: number
|
|
||||||
|
|
||||||
fastRetryMs?: number
|
|
||||||
fastRetryMax?: number
|
|
||||||
fastRetryWindowMs?: number
|
|
||||||
|
|
||||||
thumbsJPGUrl?: string | null
|
|
||||||
thumbsCandidates?: Array<string | null | undefined>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ModelPreview({
|
export default function ModelPreview({
|
||||||
jobId,
|
jobId,
|
||||||
|
live = false,
|
||||||
thumbTick,
|
thumbTick,
|
||||||
autoTickMs = 10_000,
|
autoTickMs = 10_000,
|
||||||
blur = false,
|
blur = false,
|
||||||
className,
|
className,
|
||||||
|
fit = 'cover',
|
||||||
roomStatus,
|
roomStatus,
|
||||||
alignStartAt,
|
fallbackSrc,
|
||||||
alignEndAt = null,
|
|
||||||
alignEveryMs,
|
|
||||||
fastRetryMs,
|
|
||||||
fastRetryMax,
|
|
||||||
fastRetryWindowMs,
|
|
||||||
thumbsJPGUrl,
|
|
||||||
thumbsCandidates,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
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 [popupMuted, setPopupMuted] = useState(true)
|
||||||
|
const [popupVolume, setPopupVolume] = useState(1)
|
||||||
|
|
||||||
const blurCls = blur ? 'blur-md' : ''
|
const blurCls = blur ? 'blur-md' : ''
|
||||||
const CONTROLBAR_H = 0
|
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 rootRef = useRef<HTMLDivElement | null>(null)
|
const tick = live
|
||||||
|
? (typeof thumbTick === 'number' ? thumbTick : localTick)
|
||||||
|
: 0
|
||||||
|
|
||||||
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
|
const previewSrc = useMemo(() => {
|
||||||
const pageVisibleRef = useRef(true)
|
// Nur bereits vorhandenes preview.jpg anfordern, niemals on-the-fly generieren
|
||||||
|
return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${tick}`
|
||||||
|
}, [jobId, tick])
|
||||||
|
|
||||||
const [pageVisible, setPageVisible] = useState(true)
|
const fallbackImgSrc = useMemo(() => {
|
||||||
const [popupMuted, setPopupMuted] = useState(true)
|
const s = String(fallbackSrc ?? '').trim()
|
||||||
const [popupVolume, setPopupVolume] = useState(1)
|
if (s) return s
|
||||||
|
return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1`
|
||||||
|
}, [fallbackSrc, jobId])
|
||||||
|
|
||||||
// inView als State (brauchen wir für eager/lazy + fetchPriority + UI)
|
const hq = useMemo(
|
||||||
const [inView, setInView] = useState(false)
|
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
||||||
const inViewRef = useRef(false)
|
[jobId]
|
||||||
const shouldRenderPreview = inView && pageVisible
|
)
|
||||||
|
|
||||||
const [localTick, setLocalTick] = useState(0)
|
|
||||||
const [directImgError, setDirectImgError] = useState(false)
|
|
||||||
const [apiImgError, setApiImgError] = useState(false)
|
|
||||||
|
|
||||||
const retryT = useRef<number | null>(null)
|
|
||||||
const fastTries = useRef(0)
|
|
||||||
const hadSuccess = useRef(false)
|
|
||||||
const enteredViewOnce = useRef(false)
|
|
||||||
|
|
||||||
const toMs = (v: any): number => {
|
|
||||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
|
||||||
if (v instanceof Date) return v.getTime()
|
|
||||||
const ms = Date.parse(String(v ?? ''))
|
|
||||||
return Number.isFinite(ms) ? ms : NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeUrl = (u?: string | null): string => {
|
|
||||||
const s = String(u ?? '').trim()
|
|
||||||
if (!s) return ''
|
|
||||||
if (/^https?:\/\//i.test(s)) return s
|
|
||||||
if (s.startsWith('/')) return s
|
|
||||||
return `/${s}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const thumbsCandidatesKey = useMemo(() => {
|
|
||||||
const list = [
|
|
||||||
thumbsJPGUrl,
|
|
||||||
...(Array.isArray(thumbsCandidates) ? thumbsCandidates : []),
|
|
||||||
]
|
|
||||||
.map(normalizeUrl)
|
|
||||||
.filter(Boolean)
|
|
||||||
|
|
||||||
// Reihenfolge behalten, nur dedupe
|
|
||||||
return Array.from(new Set(list)).join('|')
|
|
||||||
}, [thumbsJPGUrl, thumbsCandidates])
|
|
||||||
|
|
||||||
// ✅ visibilitychange -> nur REF updaten
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onVis = () => {
|
const onVis = () => setPageVisible(!document.hidden)
|
||||||
const vis = !document.hidden
|
|
||||||
pageVisibleRef.current = vis
|
|
||||||
setPageVisible(vis) // ✅ sorgt dafür, dass Tick-Effect neu aufgebaut wird
|
|
||||||
}
|
|
||||||
const vis = !document.hidden
|
|
||||||
pageVisibleRef.current = vis
|
|
||||||
setPageVisible(vis)
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
return () => document.removeEventListener('visibilitychange', onVis)
|
return () => document.removeEventListener('visibilitychange', onVis)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (retryT.current) window.clearTimeout(retryT.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ✅ IntersectionObserver: setState nur bei tatsächlichem Wechsel
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current
|
const el = rootRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -132,15 +81,11 @@ export default function ModelPreview({
|
|||||||
const obs = new IntersectionObserver(
|
const obs = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
const next = Boolean(entry && (entry.isIntersecting || entry.intersectionRatio > 0))
|
setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0))
|
||||||
if (next === inViewRef.current) return
|
|
||||||
inViewRef.current = next
|
|
||||||
setInView(next)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: null,
|
root: null,
|
||||||
threshold: 0,
|
threshold: 0.01,
|
||||||
rootMargin: '300px 0px',
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -148,125 +93,27 @@ export default function ModelPreview({
|
|||||||
return () => obs.disconnect()
|
return () => obs.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ✅ einmaliger Tick beim ersten Sichtbarwerden (nur wenn Parent nicht tickt)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setImgError(false)
|
||||||
|
}, [jobId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgError(false)
|
||||||
|
}, [previewSrc])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!live) return
|
||||||
if (typeof thumbTick === 'number') return
|
if (typeof thumbTick === 'number') return
|
||||||
if (!inView) return
|
if (!inView) return
|
||||||
if (!pageVisibleRef.current) return
|
if (!pageVisible) return
|
||||||
if (enteredViewOnce.current) return
|
|
||||||
enteredViewOnce.current = true
|
|
||||||
setLocalTick((x) => x + 1)
|
|
||||||
}, [inView, thumbTick])
|
|
||||||
|
|
||||||
// ✅ lokales Ticken nur wenn nötig (kein Timer wenn Parent tickt / offscreen / tab hidden)
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof thumbTick === 'number') return
|
|
||||||
if (!inView) return
|
|
||||||
if (!pageVisibleRef.current) return
|
|
||||||
|
|
||||||
const period = Number(alignEveryMs ?? autoTickMs ?? 10_000)
|
|
||||||
if (!Number.isFinite(period) || period <= 0) return
|
|
||||||
|
|
||||||
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
|
|
||||||
const endMs = alignEndAt ? toMs(alignEndAt) : NaN
|
|
||||||
|
|
||||||
// aligned schedule
|
|
||||||
if (Number.isFinite(startMs)) {
|
|
||||||
let t: number | undefined
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
// ✅ wenn tab inzwischen hidden wurde, keine neuen timeouts schedulen
|
|
||||||
if (!pageVisibleRef.current) return
|
|
||||||
const now = Date.now()
|
|
||||||
if (Number.isFinite(endMs) && now >= endMs) return
|
|
||||||
|
|
||||||
const elapsed = Math.max(0, now - startMs)
|
|
||||||
const rem = elapsed % period
|
|
||||||
const wait = rem === 0 ? period : period - rem
|
|
||||||
|
|
||||||
t = window.setTimeout(() => {
|
|
||||||
// ✅ nochmal checken, falls inzwischen offscreen/hidden
|
|
||||||
if (!inViewRef.current) return
|
|
||||||
if (!pageVisibleRef.current) return
|
|
||||||
setLocalTick((x) => x + 1)
|
|
||||||
schedule()
|
|
||||||
}, wait)
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule()
|
|
||||||
return () => {
|
|
||||||
if (t) window.clearTimeout(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback interval
|
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
if (!inViewRef.current) return
|
|
||||||
if (!pageVisibleRef.current) return
|
|
||||||
setLocalTick((x) => x + 1)
|
setLocalTick((x) => x + 1)
|
||||||
}, period)
|
setImgError(false)
|
||||||
|
}, autoTickMs)
|
||||||
|
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [thumbTick, autoTickMs, inView, pageVisible, alignStartAt, alignEndAt, alignEveryMs])
|
}, [live, thumbTick, inView, pageVisible, autoTickMs])
|
||||||
|
|
||||||
// ✅ tick Quelle
|
|
||||||
const rawTick = typeof thumbTick === 'number' ? thumbTick : localTick
|
|
||||||
|
|
||||||
// ✅ WICHTIG: Offscreen NICHT ständig src ändern (sonst trotzdem Requests!)
|
|
||||||
// Wir "freezen" den Tick, solange inView=false oder tab hidden
|
|
||||||
const frozenTickRef = useRef(0)
|
|
||||||
const [frozenTick, setFrozenTick] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inView) return
|
|
||||||
if (!pageVisibleRef.current) return
|
|
||||||
frozenTickRef.current = rawTick
|
|
||||||
setFrozenTick(rawTick)
|
|
||||||
}, [rawTick, inView])
|
|
||||||
|
|
||||||
// bei neuem *sichtbaren* Tick Error-Flag zurücksetzen
|
|
||||||
useEffect(() => {
|
|
||||||
setDirectImgError(false)
|
|
||||||
setApiImgError(false)
|
|
||||||
}, [frozenTick])
|
|
||||||
|
|
||||||
// bei Job-Wechsel reset
|
|
||||||
useEffect(() => {
|
|
||||||
hadSuccess.current = false
|
|
||||||
fastTries.current = 0
|
|
||||||
enteredViewOnce.current = false
|
|
||||||
setDirectImgError(false)
|
|
||||||
setApiImgError(false)
|
|
||||||
|
|
||||||
if (inViewRef.current && pageVisibleRef.current) {
|
|
||||||
setLocalTick((x) => x + 1)
|
|
||||||
}
|
|
||||||
}, [jobId, thumbsCandidatesKey])
|
|
||||||
|
|
||||||
const thumb = useMemo(
|
|
||||||
() => `/api/preview?id=${encodeURIComponent(jobId)}&v=${frozenTick}`,
|
|
||||||
[jobId, frozenTick]
|
|
||||||
)
|
|
||||||
|
|
||||||
const hq = useMemo(
|
|
||||||
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
|
|
||||||
[jobId]
|
|
||||||
)
|
|
||||||
|
|
||||||
const directThumbCandidates = useMemo(() => {
|
|
||||||
if (!thumbsCandidatesKey) return []
|
|
||||||
return thumbsCandidatesKey.split('|')
|
|
||||||
}, [thumbsCandidatesKey])
|
|
||||||
|
|
||||||
const directThumb = directThumbCandidates[0] || ''
|
|
||||||
const useDirectThumb = Boolean(directThumb) && !directImgError
|
|
||||||
const currentImgSrc = useMemo(() => {
|
|
||||||
if (useDirectThumb) {
|
|
||||||
const sep = directThumb.includes('?') ? '&' : '?'
|
|
||||||
return `${directThumb}${sep}v=${encodeURIComponent(String(frozenTick))}`
|
|
||||||
}
|
|
||||||
return thumb
|
|
||||||
}, [useDirectThumb, directThumb, frozenTick, thumb])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverPopover
|
<HoverPopover
|
||||||
@ -275,10 +122,7 @@ export default function ModelPreview({
|
|||||||
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
<div className="w-[420px] max-w-[calc(100vw-1.5rem)]">
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg overflow-hidden bg-black"
|
className="relative rounded-lg overflow-hidden bg-black"
|
||||||
style={{
|
style={{ paddingBottom: '56.25%' }}
|
||||||
// 16:9 Videofläche + feste 30px Controlbar
|
|
||||||
paddingBottom: `calc(56.25% + ${CONTROLBAR_H}px)`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<LiveVideo
|
<LiveVideo
|
||||||
@ -318,13 +162,11 @@ export default function ModelPreview({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-[13px] leading-none">
|
|
||||||
{popupMuted ? (
|
{popupMuted ? (
|
||||||
<SpeakerXMarkIcon className="h-4 w-4" />
|
<SpeakerXMarkIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<SpeakerWaveIcon className="h-4 w-4" />
|
<SpeakerWaveIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -353,71 +195,33 @@ export default function ModelPreview({
|
|||||||
'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden',
|
'block relative rounded bg-gray-100 dark:bg-white/5 overflow-hidden',
|
||||||
className || 'w-full h-full',
|
className || 'w-full h-full',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation()
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
}}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => {
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onTouchStart={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{shouldRenderPreview ? (
|
{inView ? (
|
||||||
!apiImgError ? (
|
!imgError ? (
|
||||||
<img
|
<img
|
||||||
src={currentImgSrc}
|
src={previewSrc}
|
||||||
loading="eager"
|
loading="lazy"
|
||||||
fetchPriority="high"
|
|
||||||
decoding="async"
|
decoding="async"
|
||||||
alt=""
|
alt=""
|
||||||
className={['block w-full h-full object-cover object-center', blurCls].filter(Boolean).join(' ')}
|
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
||||||
onLoad={() => {
|
onLoad={() => setImgError(false)}
|
||||||
hadSuccess.current = true
|
onError={() => setImgError(true)}
|
||||||
fastTries.current = 0
|
|
||||||
if (retryT.current) window.clearTimeout(retryT.current)
|
|
||||||
|
|
||||||
if (useDirectThumb) setDirectImgError(false)
|
|
||||||
else setApiImgError(false)
|
|
||||||
}}
|
|
||||||
onError={() => {
|
|
||||||
if (useDirectThumb) {
|
|
||||||
setDirectImgError(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setApiImgError(true)
|
|
||||||
|
|
||||||
if (!fastRetryMs) return
|
|
||||||
if (!inViewRef.current || !pageVisibleRef.current) return
|
|
||||||
if (hadSuccess.current) return
|
|
||||||
|
|
||||||
const startMs = alignStartAt ? toMs(alignStartAt) : NaN
|
|
||||||
const windowMs = Number(fastRetryWindowMs ?? 60_000)
|
|
||||||
const withinWindow = !Number.isFinite(startMs) || Date.now() - startMs < windowMs
|
|
||||||
if (!withinWindow) return
|
|
||||||
|
|
||||||
const max = Number(fastRetryMax ?? 25)
|
|
||||||
if (fastTries.current >= max) return
|
|
||||||
|
|
||||||
if (retryT.current) window.clearTimeout(retryT.current)
|
|
||||||
retryT.current = window.setTimeout(() => {
|
|
||||||
fastTries.current += 1
|
|
||||||
setApiImgError(false)
|
|
||||||
setLocalTick((x) => x + 1)
|
|
||||||
}, fastRetryMs)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 grid place-items-center px-1 text-center text-[10px] text-gray-500 dark:text-gray-400">
|
<img
|
||||||
keine Vorschau
|
src={fallbackImgSrc}
|
||||||
</div>
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
alt=""
|
||||||
|
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 bg-gray-100 dark:bg-white/5" />
|
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</HoverPopover>
|
</HoverPopover>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user