updated
This commit is contained in:
parent
d5c5a8488c
commit
9e21121f8b
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,3 +3,4 @@ records
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
backend/generated
|
backend/generated
|
||||||
backend/nsfwapp.exe
|
backend/nsfwapp.exe
|
||||||
|
backend/web/dist
|
||||||
|
|||||||
@ -301,9 +301,7 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok {
|
publishJobUpsertsForModelKey(modelKey)
|
||||||
publishRoomStateForModel(sm)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
|
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
|
||||||
@ -333,10 +331,33 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok {
|
publishJobUpsertsForModelKey(modelKey)
|
||||||
publishRoomStateForModel(sm)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishJobUpsertsForModelKey(modelKey string) {
|
||||||
|
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
|
||||||
|
if modelKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
list := make([]*RecordJob, 0, len(jobs))
|
||||||
|
for _, j := range jobs {
|
||||||
|
if j == nil || j.Hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sseModelEventNameForJob(j) != modelKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := *j
|
||||||
|
list = append(list, &c)
|
||||||
|
}
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
for _, j := range list {
|
||||||
|
publishJobUpsert(j)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Profilbild Download + Persist (online -> offline) ---
|
// --- Profilbild Download + Persist (online -> offline) ---
|
||||||
@ -466,6 +487,37 @@ func cbApplySnapshot(rooms []ChaturbateRoom) time.Time {
|
|||||||
return fetchedAtNow
|
return fetchedAtNow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) {
|
||||||
|
rooms, err := fetchChaturbateOnlineRooms(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cbMu.Lock()
|
||||||
|
cb.LastErr = err.Error()
|
||||||
|
cb.Rooms = nil
|
||||||
|
cb.RoomsByUser = nil
|
||||||
|
cb.LiteByUser = nil
|
||||||
|
cbMu.Unlock()
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedAtNow := cbApplySnapshot(rooms)
|
||||||
|
|
||||||
|
if cbModelStore != nil {
|
||||||
|
// ✅ bekannten Store sofort auf aktuellen Snapshot ziehen
|
||||||
|
syncChaturbateRoomStateIntoModelStore(
|
||||||
|
cbModelStore,
|
||||||
|
append([]ChaturbateRoom(nil), rooms...),
|
||||||
|
fetchedAtNow,
|
||||||
|
)
|
||||||
|
|
||||||
|
// optional / best effort
|
||||||
|
if len(rooms) > 0 {
|
||||||
|
go cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedAtNow, nil
|
||||||
|
}
|
||||||
|
|
||||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||||
func startChaturbateOnlinePoller(store *ModelStore) {
|
func startChaturbateOnlinePoller(store *ModelStore) {
|
||||||
@ -925,75 +977,48 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Snapshot Cache lesen (nur Lite)
|
|
||||||
// ---------------------------
|
|
||||||
cbMu.RLock()
|
|
||||||
fetchedAt := cb.FetchedAt
|
|
||||||
lastErr := cb.LastErr
|
|
||||||
lastAttempt := cb.LastAttempt
|
|
||||||
liteByUser := cb.LiteByUser
|
|
||||||
cbMu.RUnlock()
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// ✅ HLS URL Refresh für laufende Jobs (best effort)
|
|
||||||
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
|
|
||||||
// und nur wenn User gerade online ist.
|
|
||||||
// ---------------------------
|
|
||||||
if onlySpecificUsers && liteByUser != nil {
|
|
||||||
const hlsMinInterval = 12 * time.Second // throttle pro user
|
|
||||||
|
|
||||||
for _, u := range users {
|
|
||||||
rm, ok := liteByUser[u]
|
|
||||||
if !ok {
|
|
||||||
continue // offline -> nichts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: nur wenn wirklich "public" (reduziert unnötige fetches)
|
|
||||||
// Wenn du auch in "private" previewen willst, entferne diesen Block.
|
|
||||||
show := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
|
|
||||||
if show == "offline" || show == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// throttle
|
|
||||||
if !shouldRefreshHLS(u, hlsMinInterval) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// HLS holen (kurzer Timeout – soll /online nicht blockieren)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jobs aktualisieren + ggf. Preview stoppen
|
|
||||||
refreshRunningJobsHLS(u, newHls, cookieHeader, reqUA)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
|
||||||
// Persist "last seen online/offline" für explizit angefragte User
|
|
||||||
// ---------------------------
|
|
||||||
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
|
|
||||||
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
|
|
||||||
for _, u := range users {
|
|
||||||
_, isOnline := liteByUser[u]
|
|
||||||
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Refresh/Bootstrap-Strategie
|
// Refresh/Bootstrap-Strategie
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
const bootstrapCooldown = 8 * time.Second
|
const bootstrapCooldown = 8 * time.Second
|
||||||
|
|
||||||
needBootstrap := fetchedAt.IsZero()
|
// ersten Snapshot lesen
|
||||||
shouldTriggerFetch := wantRefresh || (needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown)
|
cbMu.RLock()
|
||||||
|
fetchedAt := cb.FetchedAt
|
||||||
|
lastErr := cb.LastErr
|
||||||
|
lastAttempt := cb.LastAttempt
|
||||||
|
cbMu.RUnlock()
|
||||||
|
|
||||||
if shouldTriggerFetch {
|
needBootstrap := fetchedAt.IsZero()
|
||||||
|
|
||||||
|
// ✅ Bei explizitem refresh synchron aktualisieren,
|
||||||
|
// damit die Response garantiert den neuesten Stand enthält.
|
||||||
|
if wantRefresh {
|
||||||
|
cbRefreshMu.Lock()
|
||||||
|
if cbRefreshInFlight {
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
|
} else {
|
||||||
|
cbRefreshInFlight = true
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
|
|
||||||
|
cbMu.Lock()
|
||||||
|
cb.LastAttempt = time.Now()
|
||||||
|
cbMu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
_, err := refreshChaturbateSnapshotNow(ctx)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
cbRefreshMu.Lock()
|
||||||
|
cbRefreshInFlight = false
|
||||||
|
cbRefreshMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Fehler nur im Cache halten; Antwort wird unten aus aktuellem Snapshot gebaut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown {
|
||||||
|
// ✅ Bootstrap darf weiterhin asynchron bleiben
|
||||||
cbRefreshMu.Lock()
|
cbRefreshMu.Lock()
|
||||||
if cbRefreshInFlight {
|
if cbRefreshInFlight {
|
||||||
cbRefreshMu.Unlock()
|
cbRefreshMu.Unlock()
|
||||||
@ -1013,33 +1038,68 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
rooms, err := fetchChaturbateOnlineRooms(ctx)
|
defer cancel()
|
||||||
cancel()
|
|
||||||
|
|
||||||
if err != nil {
|
_, _ = refreshChaturbateSnapshotNow(ctx)
|
||||||
cbMu.Lock()
|
|
||||||
cb.LastErr = err.Error()
|
|
||||||
cb.Rooms = nil
|
|
||||||
cb.RoomsByUser = nil
|
|
||||||
cb.LiteByUser = nil
|
|
||||||
// fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit)
|
|
||||||
cbMu.Unlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchedAtNow := cbApplySnapshot(rooms)
|
|
||||||
|
|
||||||
if cbModelStore != nil {
|
|
||||||
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(rooms, fetchedAtNow)
|
|
||||||
|
|
||||||
if len(rooms) > 0 {
|
|
||||||
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ JETZT erst den finalen Snapshot lesen
|
||||||
|
cbMu.RLock()
|
||||||
|
fetchedAt = cb.FetchedAt
|
||||||
|
lastErr = cb.LastErr
|
||||||
|
lastAttempt = cb.LastAttempt
|
||||||
|
liteByUser := cb.LiteByUser
|
||||||
|
cbMu.RUnlock()
|
||||||
|
|
||||||
|
needBootstrap = fetchedAt.IsZero()
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// ✅ HLS URL Refresh für laufende Jobs (best effort)
|
||||||
|
// Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so)
|
||||||
|
// und nur wenn User gerade online ist.
|
||||||
|
// ---------------------------
|
||||||
|
const hlsMinInterval = 12 * time.Second
|
||||||
|
|
||||||
|
if onlySpecificUsers && liteByUser != nil {
|
||||||
|
for _, u := range users {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Persist "last seen online/offline" für explizit angefragte User
|
||||||
|
// ---------------------------
|
||||||
|
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
|
||||||
|
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
for _, u := range users {
|
||||||
|
_, isOnline := liteByUser[u]
|
||||||
|
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// Rooms bauen (LITE, O(Anzahl requested Users))
|
// Rooms bauen (LITE, O(Anzahl requested Users))
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|||||||
@ -629,6 +629,35 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username := extractUsername(job.SourceURL)
|
||||||
|
if strings.TrimSpace(username) != "" {
|
||||||
|
cookie := strings.TrimSpace(job.PreviewCookie)
|
||||||
|
ua := strings.TrimSpace(job.PreviewUA)
|
||||||
|
if ua == "" {
|
||||||
|
ua = "Mozilla/5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second)
|
||||||
|
newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua)
|
||||||
|
cancelRefresh()
|
||||||
|
|
||||||
|
if err == nil && strings.TrimSpace(newHls) != "" {
|
||||||
|
jobsMu.Lock()
|
||||||
|
oldHls := strings.TrimSpace(job.PreviewM3U8)
|
||||||
|
job.PreviewM3U8 = strings.TrimSpace(newHls)
|
||||||
|
job.PreviewCookie = cookie
|
||||||
|
job.PreviewUA = ua
|
||||||
|
job.PreviewState = ""
|
||||||
|
job.PreviewStateAt = ""
|
||||||
|
job.PreviewStateMsg = ""
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
if oldHls != "" && oldHls != strings.TrimSpace(newHls) {
|
||||||
|
stopPreview(job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ensure ffmpeg preview input data exists
|
// ensure ffmpeg preview input data exists
|
||||||
// (PreviewM3U8 + Cookie/UA werden beim Job gesetzt)
|
// (PreviewM3U8 + Cookie/UA werden beim Job gesetzt)
|
||||||
m3u8 := strings.TrimSpace(job.PreviewM3U8)
|
m3u8 := strings.TrimSpace(job.PreviewM3U8)
|
||||||
|
|||||||
@ -519,28 +519,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
|||||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req ModelFlagsPatch
|
var req ModelFlagsPatch
|
||||||
if err := modelsReadJSON(r, &req); err != nil {
|
if err := modelsReadJSON(r, &req); err != nil {
|
||||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// ✅ id optional: wenn fehlt -> per (host, modelKey) sicherstellen + id setzen
|
|
||||||
if strings.TrimSpace(req.ID) == "" {
|
|
||||||
key := strings.TrimSpace(req.ModelKey)
|
|
||||||
host := strings.TrimSpace(req.Host)
|
|
||||||
|
|
||||||
if key == "" {
|
|
||||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ensured, err := store.EnsureByHostModelKey(host, key) // host darf leer sein
|
|
||||||
if err != nil {
|
|
||||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.ID = ensured.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
store := getModelStore()
|
store := getModelStore()
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@ -548,16 +532,35 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.ID = strings.TrimSpace(req.ID)
|
||||||
|
req.ModelKey = strings.TrimSpace(req.ModelKey)
|
||||||
|
req.Host = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(req.Host)), "www.")
|
||||||
|
|
||||||
|
// Nur wenn wirklich keine ID mitkommt -> Ensure
|
||||||
|
if req.ID == "" {
|
||||||
|
if req.ModelKey == "" {
|
||||||
|
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensured, err := store.EnsureByHostModelKey(req.Host, req.ModelKey)
|
||||||
|
if err != nil {
|
||||||
|
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ID = ensured.ID
|
||||||
|
}
|
||||||
|
|
||||||
m, err := store.PatchFlags(req)
|
m, err := store.PatchFlags(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Cleanup wenn kein relevanter Flag mehr gesetzt ist
|
|
||||||
likedOn := (m.Liked != nil && *m.Liked)
|
likedOn := (m.Liked != nil && *m.Liked)
|
||||||
if !m.Watching && !m.Favorite && !likedOn {
|
if !m.Watching && !m.Favorite && !likedOn {
|
||||||
_ = store.Delete(m.ID)
|
_ = store.Delete(m.ID)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -222,9 +222,6 @@ func (s *ModelStore) SetChaturbateRoomState(
|
|||||||
seenAt = seenAt.UTC()
|
seenAt = seenAt.UTC()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET
|
SET
|
||||||
@ -444,9 +441,6 @@ func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom)
|
|||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@ -719,9 +713,6 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA
|
|||||||
onlineArg = false
|
onlineArg = false
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
res, err := s.db.Exec(`
|
res, err := s.db.Exec(`
|
||||||
UPDATE models
|
UPDATE models
|
||||||
SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3
|
SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3
|
||||||
@ -1194,9 +1185,6 @@ func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom,
|
|||||||
|
|
||||||
roomsByUser := indexRoomsByUser(rooms)
|
roomsByUser := indexRoomsByUser(rooms)
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -1569,13 +1557,11 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
|||||||
return StoredModel{}, errors.New("id fehlt")
|
return StoredModel{}, errors.New("id fehlt")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
watching, favorite, hot, keep bool
|
watching, favorite, hot, keep bool
|
||||||
liked sql.NullBool
|
liked sql.NullBool
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID).
|
err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID).
|
||||||
Scan(&watching, &favorite, &hot, &keep, &liked)
|
Scan(&watching, &favorite, &hot, &keep, &liked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1634,9 +1620,6 @@ func (s *ModelStore) Delete(id string) error {
|
|||||||
return errors.New("id fehlt")
|
return errors.New("id fehlt")
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
_, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id)
|
_, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -90,10 +89,63 @@ func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pq *PostWorkQueue) RemoveQueued(key string) bool {
|
||||||
|
if strings.TrimSpace(key) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pq.mu.Lock()
|
||||||
|
defer pq.mu.Unlock()
|
||||||
|
|
||||||
|
// running darf hier NICHT entfernt werden
|
||||||
|
if _, running := pq.runningKeys[key]; running {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, k := range pq.waitingKeys {
|
||||||
|
if k == key {
|
||||||
|
pq.waitingKeys = append(pq.waitingKeys[:i], pq.waitingKeys[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(pq.inflight, key)
|
||||||
|
if pq.queued > 0 {
|
||||||
|
pq.queued--
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Task-Element bleibt evtl. noch im Channel liegen.
|
||||||
|
// Beim Worker-Start muss es dann erkannt und übersprungen werden.
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (pq *PostWorkQueue) workerLoop(id int) {
|
func (pq *PostWorkQueue) workerLoop(id int) {
|
||||||
for task := range pq.q {
|
for task := range pq.q {
|
||||||
|
pq.mu.Lock()
|
||||||
|
_, stillInflight := pq.inflight[task.Key]
|
||||||
|
_, alreadyRunning := pq.runningKeys[task.Key]
|
||||||
|
|
||||||
|
waitingFound := false
|
||||||
|
for _, k := range pq.waitingKeys {
|
||||||
|
if k == task.Key {
|
||||||
|
waitingFound = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pq.mu.Unlock()
|
||||||
|
|
||||||
|
// queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen
|
||||||
|
if !stillInflight || (!waitingFound && !alreadyRunning) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
|
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
|
||||||
pq.ffmpegSem <- struct{}{} // kann blocken
|
pq.ffmpegSem <- struct{}{}
|
||||||
|
|
||||||
// 2) Ab hier startet er wirklich → waiting -> running
|
// 2) Ab hier startet er wirklich → waiting -> running
|
||||||
pq.mu.Lock()
|
pq.mu.Lock()
|
||||||
@ -201,7 +253,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global (oder in deinem app struct halten)
|
// global (oder in deinem app struct halten)
|
||||||
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 4
|
var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 4
|
||||||
|
|
||||||
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
||||||
|
|
||||||
@ -222,9 +274,61 @@ func startPostWorkStatusRefresher() {
|
|||||||
|
|
||||||
st := postWorkQ.StatusForKey(key)
|
st := postWorkQ.StatusForKey(key)
|
||||||
|
|
||||||
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) {
|
changed := false
|
||||||
|
|
||||||
|
// PostWork-Daten aktualisieren
|
||||||
|
if job.PostWork == nil ||
|
||||||
|
job.PostWork.State != st.State ||
|
||||||
|
job.PostWork.Position != st.Position ||
|
||||||
|
job.PostWork.Waiting != st.Waiting ||
|
||||||
|
job.PostWork.Running != st.Running ||
|
||||||
|
job.PostWork.MaxParallel != st.MaxParallel {
|
||||||
tmp := st
|
tmp := st
|
||||||
job.PostWork = &tmp
|
job.PostWork = &tmp
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status / Phase für UI vereinheitlichen
|
||||||
|
switch st.State {
|
||||||
|
case "queued":
|
||||||
|
if job.Status != JobPostwork {
|
||||||
|
job.Status = JobPostwork
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
phaseLower := strings.TrimSpace(strings.ToLower(job.Phase))
|
||||||
|
if phaseLower == "" || phaseLower == "recording" {
|
||||||
|
job.Phase = "postwork"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.Progress < 0 || job.Progress > 100 {
|
||||||
|
job.Progress = 0
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "running":
|
||||||
|
if job.Status != JobPostwork {
|
||||||
|
job.Status = JobPostwork
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
phaseLower := strings.TrimSpace(strings.ToLower(job.Phase))
|
||||||
|
|
||||||
|
// Konkrete Unterphasen NICHT überschreiben
|
||||||
|
switch phaseLower {
|
||||||
|
case "probe", "remuxing", "moving", "assets":
|
||||||
|
// ok, so lassen
|
||||||
|
default:
|
||||||
|
if phaseLower == "" || phaseLower == "recording" || phaseLower == "postwork" {
|
||||||
|
if phaseLower != "postwork" {
|
||||||
|
job.Phase = "postwork"
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
changedJobs = append(changedJobs, job)
|
changedJobs = append(changedJobs, job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1188,29 +1188,61 @@ func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
|
|||||||
// --- WebP extraction helpers ---
|
// --- WebP extraction helpers ---
|
||||||
|
|
||||||
func extractLastFrameWebP(path string) ([]byte, error) {
|
func extractLastFrameWebP(path string) ([]byte, error) {
|
||||||
cmd := exec.Command(
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(
|
||||||
|
ctx,
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
"-hide_banner", "-loglevel", "error",
|
"-hide_banner",
|
||||||
"-sseof", "-0.1",
|
"-loglevel", "error",
|
||||||
|
|
||||||
|
// relativ zum Dateiende suchen
|
||||||
|
"-sseof", "-0.25",
|
||||||
|
|
||||||
"-i", path,
|
"-i", path,
|
||||||
|
|
||||||
|
// nur den ersten Video-Stream verwenden
|
||||||
|
"-map", "0:v:0",
|
||||||
|
|
||||||
|
// alles andere hart abschalten
|
||||||
|
"-an",
|
||||||
|
"-sn",
|
||||||
|
"-dn",
|
||||||
|
|
||||||
|
// genau 1 Frame
|
||||||
"-frames:v", "1",
|
"-frames:v", "1",
|
||||||
"-vf", "scale=720:-2",
|
|
||||||
"-quality", "75",
|
// schneller skalieren
|
||||||
"-f", "image2pipe",
|
"-vf", "scale=720:-2:flags=fast_bilinear",
|
||||||
|
|
||||||
|
// WebP: Qualität + schnellerer Encode
|
||||||
"-vcodec", "libwebp",
|
"-vcodec", "libwebp",
|
||||||
|
"-quality", "75",
|
||||||
|
"-compression_level", "2",
|
||||||
|
"-preset", "photo",
|
||||||
|
|
||||||
|
"-f", "image2pipe",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
)
|
)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, fmt.Errorf("ffmpeg last-frame webp: timeout")
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
return nil, fmt.Errorf("ffmpeg last-frame webp: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
b := out.Bytes()
|
b := out.Bytes()
|
||||||
if len(b) == 0 {
|
if len(b) == 0 {
|
||||||
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output")
|
return nil, fmt.Errorf("ffmpeg last-frame webp: empty output")
|
||||||
}
|
}
|
||||||
|
|
||||||
return b, nil
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -567,6 +567,176 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
|
|||||||
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
|
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTerminalJobStatus(status any) bool {
|
||||||
|
s := strings.TrimSpace(strings.ToLower(fmt.Sprint(status)))
|
||||||
|
switch s {
|
||||||
|
case "stopped", "finished", "failed", "done", "completed", "canceled", "cancelled":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPostworkJob(job *RecordJob) bool {
|
||||||
|
if job == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := strings.TrimSpace(strings.ToLower(job.Phase))
|
||||||
|
pwKey := strings.TrimSpace(job.PostWorkKey)
|
||||||
|
|
||||||
|
// 1) expliziter Queue-Key vorhanden
|
||||||
|
if pwKey != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) postWork-Status vom Refresher/Queue vorhanden
|
||||||
|
if job.PostWork != nil {
|
||||||
|
state := strings.TrimSpace(strings.ToLower(job.PostWork.State))
|
||||||
|
if state == "queued" || state == "running" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Aufnahme ist beendet und es gibt noch eine Nachbearbeitungs-Phase
|
||||||
|
if job.EndedAt != nil && phase != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) explizite postwork-Phase
|
||||||
|
if phase == "postwork" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEffectivePostworkState(job *RecordJob) string {
|
||||||
|
if job == nil {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := strings.TrimSpace(strings.ToLower(job.Phase))
|
||||||
|
pwState := ""
|
||||||
|
hasPwKey := strings.TrimSpace(job.PostWorkKey) != ""
|
||||||
|
if job.PostWork != nil {
|
||||||
|
pwState = strings.TrimSpace(strings.ToLower(job.PostWork.State))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPostworkJob(job) {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
if isTerminalJobStatus(job.Status) {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Harte Wahrheit aus postWork
|
||||||
|
if pwState == "queued" {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
if pwState == "running" {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.PostWork != nil {
|
||||||
|
if job.PostWork.Position > 0 {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
if job.PostWork.Running > 0 && job.PostWork.Position <= 0 {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Sobald ein PostWorkKey existiert und nichts explizit "running" sagt:
|
||||||
|
// lieber queued statt alte phase dominieren lassen
|
||||||
|
if hasPwKey {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Nur noch Heuristik für Alt-Fälle ohne PostWorkKey
|
||||||
|
switch phase {
|
||||||
|
case "postwork":
|
||||||
|
if job.Progress > 0 {
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
return "queued"
|
||||||
|
case "remuxing", "moving", "assets", "probe":
|
||||||
|
return "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.EndedAt != nil {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !mustMethod(w, r, http.MethodPost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
job, ok := jobs[id]
|
||||||
|
jobsMu.Unlock()
|
||||||
|
if !ok || job == nil {
|
||||||
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isPostworkJob(job) {
|
||||||
|
http.Error(w, "job ist kein postwork-job", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state := getEffectivePostworkState(job)
|
||||||
|
if state != "queued" {
|
||||||
|
http.Error(w, "nur queued postwork-jobs können entfernt werden", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
postKey := strings.TrimSpace(job.PostWorkKey)
|
||||||
|
if postKey == "" {
|
||||||
|
http.Error(w, "postwork key fehlt", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ok := postWorkQ.RemoveQueued(postKey); !ok {
|
||||||
|
http.Error(w, "postwork job konnte nicht aus der warteschlange entfernt werden", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.TrimSpace(job.Output)
|
||||||
|
if out != "" {
|
||||||
|
_ = removeWithRetry(out)
|
||||||
|
purgeDurationCacheForPath(out)
|
||||||
|
|
||||||
|
base := filepath.Base(out)
|
||||||
|
id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||||
|
if strings.TrimSpace(id1) != "" {
|
||||||
|
removeGeneratedForID(id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jobsMu.Lock()
|
||||||
|
delete(jobs, job.ID)
|
||||||
|
jobsMu.Unlock()
|
||||||
|
|
||||||
|
publishJobRemove(job)
|
||||||
|
notifyDoneChanged()
|
||||||
|
|
||||||
|
respondJSON(w, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"id": id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
tw := &rwTrack{ResponseWriter: w}
|
tw := &rwTrack{ResponseWriter: w}
|
||||||
w = tw
|
w = tw
|
||||||
|
|||||||
@ -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/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)
|
||||||
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
|
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
|
||||||
|
|||||||
188
backend/sse.go
188
backend/sse.go
@ -41,6 +41,10 @@ type jobEvent struct {
|
|||||||
IsOnline bool `json:"isOnline,omitempty"`
|
IsOnline bool `json:"isOnline,omitempty"`
|
||||||
ModelImageURL string `json:"modelImageUrl,omitempty"`
|
ModelImageURL string `json:"modelImageUrl,omitempty"`
|
||||||
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
|
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
|
||||||
|
|
||||||
|
PostWorkKey string `json:"postWorkKey,omitempty"`
|
||||||
|
PostWork any `json:"postWork,omitempty"`
|
||||||
|
|
||||||
TS int64 `json:"ts"`
|
TS int64 `json:"ts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +53,78 @@ type ssePublishItem struct {
|
|||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTerminalJobStatusForSSE(status JobStatus) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(string(status)))
|
||||||
|
return s == "stopped" ||
|
||||||
|
s == "finished" ||
|
||||||
|
s == "failed" ||
|
||||||
|
s == "done" ||
|
||||||
|
s == "completed" ||
|
||||||
|
s == "canceled" ||
|
||||||
|
s == "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPostworkJobForSSE(j *RecordJob) bool {
|
||||||
|
if j == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
phase := strings.ToLower(strings.TrimSpace(j.Phase))
|
||||||
|
pwKey := strings.TrimSpace(j.PostWorkKey)
|
||||||
|
|
||||||
|
if pwKey != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.PostWork != nil {
|
||||||
|
// falls PostWork als struct/map vorliegt, reicht für SSE der generelle Hinweis:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if j.EndedAt != nil && phase != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if phase == "postwork" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisibleDownloadJobForSSE(j *RecordJob) bool {
|
||||||
|
if j == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isPostworkJobForSSE(j) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isTerminalJobStatusForSSE(j.Status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if j.EndedAt != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisiblePostworkJobForSSE(j *RecordJob) bool {
|
||||||
|
if j == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isPostworkJobForSSE(j) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isTerminalJobStatusForSSE(j.Status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldPublishModelEventForJob(j *RecordJob) bool {
|
||||||
|
return isVisibleDownloadJobForSSE(j) || isVisiblePostworkJobForSSE(j)
|
||||||
|
}
|
||||||
|
|
||||||
func visibleJobEventsJSON() []ssePublishItem {
|
func visibleJobEventsJSON() []ssePublishItem {
|
||||||
nowTs := time.Now().UnixMilli()
|
nowTs := time.Now().UnixMilli()
|
||||||
out := make([]ssePublishItem, 0, 64)
|
out := make([]ssePublishItem, 0, 64)
|
||||||
@ -60,6 +136,9 @@ func visibleJobEventsJSON() []ssePublishItem {
|
|||||||
if j == nil || j.Hidden {
|
if j == nil || j.Hidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !shouldPublishModelEventForJob(j) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
eventName := sseModelEventNameForJob(j)
|
eventName := sseModelEventNameForJob(j)
|
||||||
if eventName == "" {
|
if eventName == "" {
|
||||||
@ -80,6 +159,8 @@ func visibleJobEventsJSON() []ssePublishItem {
|
|||||||
SizeBytes: j.SizeBytes,
|
SizeBytes: j.SizeBytes,
|
||||||
DurationSeconds: j.DurationSeconds,
|
DurationSeconds: j.DurationSeconds,
|
||||||
PreviewState: j.PreviewState,
|
PreviewState: j.PreviewState,
|
||||||
|
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
|
||||||
|
PostWork: j.PostWork,
|
||||||
TS: nowTs,
|
TS: nowTs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,53 +190,13 @@ func visibleJobEventsJSON() []ssePublishItem {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func visibleRoomStateEventsJSON() []ssePublishItem {
|
|
||||||
nowTs := time.Now().UnixMilli()
|
|
||||||
out := make([]ssePublishItem, 0, 128)
|
|
||||||
|
|
||||||
if cbModelStore == nil {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
models := cbModelStore.List()
|
|
||||||
for _, sm := range models {
|
|
||||||
if strings.ToLower(strings.TrimSpace(sm.Host)) != "chaturbate.com" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
modelKey := strings.ToLower(strings.TrimSpace(sm.ModelKey))
|
|
||||||
if modelKey == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := jobEvent{
|
|
||||||
Type: "room_state",
|
|
||||||
Model: modelKey,
|
|
||||||
RoomStatus: strings.ToLower(strings.TrimSpace(sm.RoomStatus)),
|
|
||||||
IsOnline: sm.IsOnline,
|
|
||||||
ModelImageURL: strings.TrimSpace(sm.ImageURL),
|
|
||||||
ModelChatRoomURL: strings.TrimSpace(sm.ChatRoomURL),
|
|
||||||
TS: nowTs,
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
out = append(out, ssePublishItem{
|
|
||||||
EventName: modelKey,
|
|
||||||
Data: b,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func publishJobUpsert(j *RecordJob) {
|
func publishJobUpsert(j *RecordJob) {
|
||||||
if j == nil || j.Hidden {
|
if j == nil || j.Hidden {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !shouldPublishModelEventForJob(j) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
eventName := sseModelEventNameForJob(j)
|
eventName := sseModelEventNameForJob(j)
|
||||||
if eventName == "" {
|
if eventName == "" {
|
||||||
@ -177,6 +218,8 @@ func publishJobUpsert(j *RecordJob) {
|
|||||||
SizeBytes: j.SizeBytes,
|
SizeBytes: j.SizeBytes,
|
||||||
DurationSeconds: j.DurationSeconds,
|
DurationSeconds: j.DurationSeconds,
|
||||||
PreviewState: j.PreviewState,
|
PreviewState: j.PreviewState,
|
||||||
|
PostWorkKey: strings.TrimSpace(j.PostWorkKey),
|
||||||
|
PostWork: j.PostWork,
|
||||||
TS: time.Now().UnixMilli(),
|
TS: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,34 +258,6 @@ func publishJobRemove(j *RecordJob) {
|
|||||||
publishSSE(eventName, b)
|
publishSSE(eventName, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishRoomStateForModel(sm *StoredModel) {
|
|
||||||
if sm == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.ToLower(strings.TrimSpace(sm.Host)) != "chaturbate.com" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modelKey := strings.ToLower(strings.TrimSpace(sm.ModelKey))
|
|
||||||
if modelKey == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := jobEvent{
|
|
||||||
Type: "room_state",
|
|
||||||
Model: modelKey,
|
|
||||||
RoomStatus: strings.ToLower(strings.TrimSpace(sm.RoomStatus)),
|
|
||||||
IsOnline: sm.IsOnline,
|
|
||||||
ModelImageURL: strings.TrimSpace(sm.ImageURL),
|
|
||||||
ModelChatRoomURL: strings.TrimSpace(sm.ChatRoomURL),
|
|
||||||
TS: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
b, _ := json.Marshal(payload)
|
|
||||||
publishSSE(modelKey, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sseModelEventNameForJob(j *RecordJob) string {
|
func sseModelEventNameForJob(j *RecordJob) string {
|
||||||
if j == nil {
|
if j == nil {
|
||||||
return ""
|
return ""
|
||||||
@ -364,37 +379,6 @@ func initSSE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Room state snapshot: 1 SSE event per second for known chaturbate models
|
|
||||||
go func() {
|
|
||||||
t := time.NewTicker(1 * time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
|
|
||||||
lastByModel := map[string]string{}
|
|
||||||
|
|
||||||
for range t.C {
|
|
||||||
events := visibleRoomStateEventsJSON()
|
|
||||||
nextByModel := make(map[string]string, len(events))
|
|
||||||
|
|
||||||
for _, ev := range events {
|
|
||||||
if len(ev.Data) == 0 || ev.EventName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
key := ev.EventName
|
|
||||||
payloadKey := string(ev.Data)
|
|
||||||
nextByModel[key] = payloadKey
|
|
||||||
|
|
||||||
if prev, ok := lastByModel[key]; ok && prev == payloadKey {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
publishSSE(ev.EventName, ev.Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
lastByModel = nextByModel
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishSSE(eventName string, data []byte) {
|
func publishSSE(eventName string, data []byte) {
|
||||||
|
|||||||
1
backend/web/dist/assets/index-C6R3TW-y.css
vendored
1
backend/web/dist/assets/index-C6R3TW-y.css
vendored
File diff suppressed because one or more lines are too long
449
backend/web/dist/assets/index-z2cKWgjr.js
vendored
449
backend/web/dist/assets/index-z2cKWgjr.js
vendored
File diff suppressed because one or more lines are too long
14
backend/web/dist/index.html
vendored
14
backend/web/dist/index.html
vendored
@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<title>App</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-z2cKWgjr.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C6R3TW-y.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1
backend/web/dist/vite.svg
vendored
1
backend/web/dist/vite.svg
vendored
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -57,7 +57,7 @@ type RecorderSettingsState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JobEvent = {
|
type JobEvent = {
|
||||||
type?: 'job_upsert' | 'job_remove' | 'room_state'
|
type?: 'job_upsert' | 'job_remove'
|
||||||
model?: string
|
model?: string
|
||||||
jobId?: string
|
jobId?: string
|
||||||
status?: string
|
status?: string
|
||||||
@ -77,6 +77,15 @@ type JobEvent = {
|
|||||||
modelImageUrl?: string
|
modelImageUrl?: string
|
||||||
modelChatRoomUrl?: string
|
modelChatRoomUrl?: string
|
||||||
ts?: number
|
ts?: number
|
||||||
|
|
||||||
|
postWorkKey?: string
|
||||||
|
postWork?: {
|
||||||
|
state?: string
|
||||||
|
position?: number
|
||||||
|
waiting?: number
|
||||||
|
running?: number
|
||||||
|
maxParallel?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||||
@ -325,6 +334,78 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
|
|||||||
return base ? base : null
|
return base ? base : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelEventKeyFromJob(job: RecordJob): string {
|
||||||
|
const raw = String((job as any)?.sourceUrl ?? '').trim()
|
||||||
|
const norm0 = normalizeHttpUrl(raw)
|
||||||
|
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||||||
|
|
||||||
|
const keyFromUrl = providerKeyLowerFromUrl(norm)
|
||||||
|
if (keyFromUrl) return keyFromUrl
|
||||||
|
|
||||||
|
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
|
||||||
|
return keyFromFile
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
|
||||||
|
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
let host = ''
|
||||||
|
try {
|
||||||
|
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
|
||||||
|
const u0 = extractFirstUrl(raw)
|
||||||
|
if (u0) {
|
||||||
|
const u = new URL(u0)
|
||||||
|
host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyFromFile) {
|
||||||
|
return { modelKey: keyFromFile, host }
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
|
||||||
|
const u0 = extractFirstUrl(raw)
|
||||||
|
if (u0) {
|
||||||
|
const norm0 = normalizeHttpUrl(u0)
|
||||||
|
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||||||
|
const keyFromUrl = providerKeyLowerFromUrl(norm)
|
||||||
|
if (keyFromUrl) {
|
||||||
|
return { modelKey: keyFromUrl, host }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modelKey: '', host }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTerminalJobStatusForCount = (status?: unknown) => {
|
||||||
|
const s = String(status ?? '').trim().toLowerCase()
|
||||||
|
return (
|
||||||
|
s === 'stopped' ||
|
||||||
|
s === 'finished' ||
|
||||||
|
s === 'failed' ||
|
||||||
|
s === 'done' ||
|
||||||
|
s === 'completed' ||
|
||||||
|
s === 'canceled' ||
|
||||||
|
s === 'cancelled'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPostworkJobForCount = (job: RecordJob): boolean => {
|
||||||
|
const anyJ = job as any
|
||||||
|
const phase = String(anyJ.phase ?? '').trim()
|
||||||
|
const pw = anyJ.postWork
|
||||||
|
const pwKey = String(anyJ.postWorkKey ?? '').trim()
|
||||||
|
|
||||||
|
if (pwKey) return true
|
||||||
|
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
|
||||||
|
if (job.endedAt && phase) return true
|
||||||
|
if (phase.toLowerCase() === 'postwork') return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
||||||
const [authChecked, setAuthChecked] = useState(false)
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
@ -759,6 +840,14 @@ export default function App() {
|
|||||||
isOnline: msg.isOnline ?? (prevJob as any)?.isOnline,
|
isOnline: msg.isOnline ?? (prevJob as any)?.isOnline,
|
||||||
modelImageUrl: msg.modelImageUrl ?? (prevJob as any)?.modelImageUrl,
|
modelImageUrl: msg.modelImageUrl ?? (prevJob as any)?.modelImageUrl,
|
||||||
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prevJob as any)?.modelChatRoomUrl,
|
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prevJob as any)?.modelChatRoomUrl,
|
||||||
|
|
||||||
|
postWorkKey: msg.postWorkKey ?? (prevJob as any)?.postWorkKey,
|
||||||
|
postWork: msg.postWork
|
||||||
|
? {
|
||||||
|
...((prevJob as any)?.postWork ?? {}),
|
||||||
|
...msg.postWork,
|
||||||
|
}
|
||||||
|
: (prevJob as any)?.postWork,
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
let next: RecordJob[]
|
let next: RecordJob[]
|
||||||
@ -776,7 +865,9 @@ export default function App() {
|
|||||||
setPlayerJob((prev) => {
|
setPlayerJob((prev) => {
|
||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return String((prev as any).id ?? '') === jobId
|
return String((prev as any).id ?? '') === jobId
|
||||||
? ({ ...prev, ...{
|
? ({
|
||||||
|
...prev,
|
||||||
|
...{
|
||||||
id: jobId,
|
id: jobId,
|
||||||
status: (msg.status as any) ?? (prev as any).status,
|
status: (msg.status as any) ?? (prev as any).status,
|
||||||
sourceUrl: msg.sourceUrl ?? (prev as any).sourceUrl,
|
sourceUrl: msg.sourceUrl ?? (prev as any).sourceUrl,
|
||||||
@ -794,7 +885,16 @@ export default function App() {
|
|||||||
isOnline: msg.isOnline ?? (prev as any).isOnline,
|
isOnline: msg.isOnline ?? (prev as any).isOnline,
|
||||||
modelImageUrl: msg.modelImageUrl ?? (prev as any).modelImageUrl,
|
modelImageUrl: msg.modelImageUrl ?? (prev as any).modelImageUrl,
|
||||||
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prev as any).modelChatRoomUrl,
|
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prev as any).modelChatRoomUrl,
|
||||||
} } as any)
|
|
||||||
|
postWorkKey: msg.postWorkKey ?? (prev as any).postWorkKey,
|
||||||
|
postWork: msg.postWork
|
||||||
|
? {
|
||||||
|
...((prev as any).postWork ?? {}),
|
||||||
|
...msg.postWork,
|
||||||
|
}
|
||||||
|
: (prev as any).postWork,
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
: prev
|
: prev
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -827,42 +927,17 @@ export default function App() {
|
|||||||
modelEventNamesRef.current.delete(n)
|
modelEventNamesRef.current.delete(n)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const isTerminalJobStatus = (status: unknown): boolean => {
|
const isVisibleDownloadJobForSSE = (job: RecordJob): boolean => {
|
||||||
const s = String(status ?? '').trim().toLowerCase()
|
if (isPostworkJobForCount(job)) return false
|
||||||
return (
|
if (isTerminalJobStatusForCount((job as any)?.status)) return false
|
||||||
s === 'stopped' ||
|
if (Boolean((job as any)?.endedAt)) return false
|
||||||
s === 'finished' ||
|
|
||||||
s === 'failed' ||
|
|
||||||
s === 'done' ||
|
|
||||||
s === 'completed' ||
|
|
||||||
s === 'canceled' ||
|
|
||||||
s === 'cancelled'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const needsModelEventListenerForJob = (job: RecordJob): boolean => {
|
|
||||||
const anyJob = job as any
|
|
||||||
|
|
||||||
// terminale Jobs brauchen keinen Listener mehr
|
|
||||||
if (isTerminalJobStatus(anyJob?.status)) return false
|
|
||||||
|
|
||||||
const phase = String(anyJob?.phase ?? '').trim().toLowerCase()
|
|
||||||
const pw = anyJob?.postWork
|
|
||||||
const pwState = String(pw?.state ?? '').trim().toLowerCase()
|
|
||||||
|
|
||||||
// normal laufender Download
|
|
||||||
if (String(anyJob?.status ?? '').trim().toLowerCase() === 'running' && !anyJob?.endedAt) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nachbearbeitung aktiv / queued
|
const isVisiblePostworkJobForSSE = (job: RecordJob): boolean => {
|
||||||
if (phase === 'postwork') return true
|
if (!isPostworkJobForCount(job)) return false
|
||||||
if (pwState === 'queued' || pwState === 'running') return true
|
if (isTerminalJobStatusForCount((job as any)?.status)) return false
|
||||||
|
return true
|
||||||
// allgemeine Busy-Phasen nach Aufnahmeende
|
|
||||||
if (anyJob?.endedAt && phase) return true
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAgoDE = (diffMs: number) => {
|
const formatAgoDE = (diffMs: number) => {
|
||||||
@ -997,9 +1072,11 @@ export default function App() {
|
|||||||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||||||
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
|
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (msg?.type === 'room_state') {
|
if (msg?.type === 'job_upsert') {
|
||||||
if (modelKey) {
|
if (modelKey) {
|
||||||
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (roomStatus) {
|
||||||
const isOnline =
|
const isOnline =
|
||||||
typeof msg?.isOnline === 'boolean'
|
typeof msg?.isOnline === 'boolean'
|
||||||
? Boolean(msg.isOnline)
|
? Boolean(msg.isOnline)
|
||||||
@ -1021,7 +1098,7 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyJobDelta(msg)
|
applyJobDelta(msg)
|
||||||
@ -1811,8 +1888,27 @@ export default function App() {
|
|||||||
return { onlineFavCount: fav, onlineLikedCount: liked }
|
return { onlineFavCount: fav, onlineLikedCount: liked }
|
||||||
}, [modelsByKey])
|
}, [modelsByKey])
|
||||||
|
|
||||||
|
const runningTabCount = useMemo(() => {
|
||||||
|
const activeDownloads = jobs.filter((j) => {
|
||||||
|
if (isPostworkJobForCount(j)) return false
|
||||||
|
if (isTerminalJobStatusForCount((j as any)?.status)) return false
|
||||||
|
if (Boolean((j as any)?.endedAt)) return false
|
||||||
|
return true
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const activePostwork = jobs.filter((j) => {
|
||||||
|
if (!isPostworkJobForCount(j)) return false
|
||||||
|
if (isTerminalJobStatusForCount((j as any)?.status)) return false
|
||||||
|
return true
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const pendingCount = pendingWatchedRooms.length
|
||||||
|
|
||||||
|
return activeDownloads + activePostwork + pendingCount
|
||||||
|
}, [jobs, pendingWatchedRooms])
|
||||||
|
|
||||||
const tabs: TabItem[] = [
|
const tabs: TabItem[] = [
|
||||||
{ id: 'running', label: 'Laufende Downloads', count: jobs.length },
|
{ id: 'running', label: 'Laufende Downloads', count: runningTabCount },
|
||||||
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
|
||||||
{ id: 'models', label: 'Models', count: modelsCount },
|
{ id: 'models', label: 'Models', count: modelsCount },
|
||||||
{ id: 'categories', label: 'Kategorien' },
|
{ id: 'categories', label: 'Kategorien' },
|
||||||
@ -2023,21 +2119,17 @@ export default function App() {
|
|||||||
|
|
||||||
// initial nur Listener für wirklich relevante Jobs / Queue setzen
|
// initial nur Listener für wirklich relevante Jobs / Queue setzen
|
||||||
|
|
||||||
// 1) aktive Jobs / Postwork
|
// 1) sichtbare Downloads + sichtbare Nacharbeiten
|
||||||
for (const j of jobsRef.current) {
|
for (const j of jobsRef.current) {
|
||||||
if (!needsModelEventListenerForJob(j)) continue
|
if (!isVisibleDownloadJobForSSE(j) && !isVisiblePostworkJobForSSE(j)) continue
|
||||||
|
|
||||||
const raw = String((j as any)?.sourceUrl ?? '')
|
const key = modelEventKeyFromJob(j)
|
||||||
const norm0 = normalizeHttpUrl(raw)
|
|
||||||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
|
||||||
|
|
||||||
const key = providerKeyLowerFromUrl(norm)
|
|
||||||
if (key) ensureModelEventListener(key)
|
if (key) ensureModelEventListener(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) queued / pending watched models
|
// 2) sichtbare Wartenden-Einträge
|
||||||
for (const key of Object.keys(pendingAutoStartByKeyRef.current || {})) {
|
for (const p of pendingWatchedRooms) {
|
||||||
const k = String(key ?? '').trim().toLowerCase()
|
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||||
if (k) ensureModelEventListener(k)
|
if (k) ensureModelEventListener(k)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2079,41 +2171,51 @@ export default function App() {
|
|||||||
modelEventNamesRef.current = new Set()
|
modelEventNamesRef.current = new Set()
|
||||||
es = null
|
es = null
|
||||||
}
|
}
|
||||||
}, [authed, loadJobs, loadDoneCount, requestFinishedReload, ensureModelEventListener])
|
}, [
|
||||||
|
authed,
|
||||||
|
loadJobs,
|
||||||
|
loadDoneCount,
|
||||||
|
requestFinishedReload,
|
||||||
|
ensureModelEventListener,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const desired = new Set<string>()
|
const desired = new Set<string>()
|
||||||
|
|
||||||
// 1) aktive Downloads + Postwork-Jobs
|
// 1) Sichtbare Downloads
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
if (!needsModelEventListenerForJob(j)) continue
|
if (!isVisibleDownloadJobForSSE(j)) continue
|
||||||
|
|
||||||
const raw = String((j as any)?.sourceUrl ?? '')
|
|
||||||
const norm0 = normalizeHttpUrl(raw)
|
|
||||||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
|
||||||
const key = providerKeyLowerFromUrl(norm)
|
|
||||||
|
|
||||||
|
const key = modelEventKeyFromJob(j)
|
||||||
if (key) desired.add(key)
|
if (key) desired.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Warteschlange / pending auto start
|
// 2) Sichtbare Nacharbeiten
|
||||||
for (const key of Object.keys(pendingAutoStartByKeyRef.current || {})) {
|
for (const j of jobs) {
|
||||||
const k = String(key ?? '').trim().toLowerCase()
|
if (!isVisiblePostworkJobForSSE(j)) continue
|
||||||
if (k) desired.add(k)
|
|
||||||
|
const key = modelEventKeyFromJob(j)
|
||||||
|
if (key) desired.add(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) fehlende Listener hinzufügen
|
// 3) Sichtbare Wartenden-Einträge
|
||||||
|
for (const p of pendingWatchedRooms) {
|
||||||
|
const key = String(p?.modelKey ?? '').trim().toLowerCase()
|
||||||
|
if (key) desired.add(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) fehlende Listener hinzufügen
|
||||||
for (const key of desired) {
|
for (const key of desired) {
|
||||||
ensureModelEventListener(key)
|
ensureModelEventListener(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) nicht mehr benötigte Listener entfernen
|
// 5) alles entfernen, was nicht mehr in Downloads / Nacharbeiten / Wartend ist
|
||||||
for (const key of Array.from(modelEventNamesRef.current)) {
|
for (const key of Array.from(modelEventNamesRef.current)) {
|
||||||
if (!desired.has(key)) {
|
if (!desired.has(key)) {
|
||||||
removeModelEventListener(key)
|
removeModelEventListener(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [jobs, pendingAutoStartByKey, ensureModelEventListener, removeModelEventListener])
|
}, [jobs, pendingWatchedRooms, ensureModelEventListener, removeModelEventListener])
|
||||||
|
|
||||||
function isChaturbate(raw: string): boolean {
|
function isChaturbate(raw: string): boolean {
|
||||||
const norm = normalizeHttpUrl(raw)
|
const norm = normalizeHttpUrl(raw)
|
||||||
@ -2148,6 +2250,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeQueuedPostworkJob(id: string) {
|
||||||
|
try {
|
||||||
|
await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onHint = (ev: Event) => {
|
const onHint = (ev: Event) => {
|
||||||
const e = ev as CustomEvent<{ delta?: number }>
|
const e = ev as CustomEvent<{ delta?: number }>
|
||||||
@ -2465,32 +2577,23 @@ export default function App() {
|
|||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||||
|
|
||||||
// helper: host schnell aus job holen (für flags ohne id)
|
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||||||
const hostFromJob = (j: RecordJob): string => {
|
if (!modelKey) {
|
||||||
try {
|
notify.error('Favorit umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||||||
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
|
return
|
||||||
const u = extractFirstUrl(urlFromJob)
|
|
||||||
if (!u) return ''
|
|
||||||
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Fast-Path: modelKey aus Dateiname => instant optimistic + 1x flags call
|
const guardKey = modelKey
|
||||||
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
|
|
||||||
if (keyFromFile) {
|
|
||||||
const guardKey = keyFromFile
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
if (flagsInFlightRef.current[guardKey]) return
|
||||||
flagsInFlightRef.current[guardKey] = true
|
flagsInFlightRef.current[guardKey] = true
|
||||||
|
|
||||||
const prev =
|
const prev =
|
||||||
modelsByKey[keyFromFile] ??
|
modelsByKey[modelKey] ??
|
||||||
({
|
({
|
||||||
id: '', // unknown (ok)
|
id: '',
|
||||||
input: '',
|
input: '',
|
||||||
host: hostFromJob(job) || undefined,
|
host: host || undefined,
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
watching: false,
|
watching: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
liked: null,
|
liked: null,
|
||||||
@ -2501,111 +2604,45 @@ export default function App() {
|
|||||||
|
|
||||||
const optimistic: StoredModel = {
|
const optimistic: StoredModel = {
|
||||||
...prev,
|
...prev,
|
||||||
modelKey: prev.modelKey || keyFromFile,
|
host: prev.host || host || undefined,
|
||||||
|
modelKey,
|
||||||
favorite: nextFav,
|
favorite: nextFav,
|
||||||
liked: nextFav ? false : prev.liked,
|
liked: nextFav ? false : prev.liked,
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
|
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||||||
upsertModelCache(optimistic)
|
upsertModelCache(optimistic)
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
if (sameAsPlayer) setPlayerModel(optimistic)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await patchModelFlags({
|
const updated = await patchModelFlags({
|
||||||
...(optimistic.id ? { id: optimistic.id } : {}),
|
...(prev.id ? { id: prev.id } : {}),
|
||||||
host: optimistic.host || hostFromJob(job) || '',
|
host: optimistic.host || '',
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
favorite: nextFav,
|
favorite: nextFav,
|
||||||
...(nextFav ? { liked: false } : {}),
|
...(nextFav ? { liked: false } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ✅ Model wurde gelöscht (204)
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
setModelsByKey((p) => {
|
setModelsByKey((p) => {
|
||||||
const { [keyFromFile]: _drop, ...rest } = p
|
const { [modelKey]: _drop, ...rest } = p
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
if (prev.id) removeModelCache(prev.id)
|
if (prev.id) removeModelCache(prev.id)
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
if (sameAsPlayer) setPlayerModel(null)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } })
|
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = lower(updated.modelKey || keyFromFile)
|
const k = lower(updated.modelKey || modelKey)
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
||||||
upsertModelCache(updated)
|
upsertModelCache(updated)
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
if (sameAsPlayer) setPlayerModel(updated)
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// rollback
|
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: prev }))
|
|
||||||
upsertModelCache(prev)
|
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
|
||||||
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
|
|
||||||
} finally {
|
|
||||||
delete flagsInFlightRef.current[guardKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Fallback: keinen modelKey aus Filename -> wie bisher resolve+ensure
|
|
||||||
let m = sameAsPlayer ? playerModel : null
|
|
||||||
if (!m) m = await resolveModelForJob(job, { ensure: true })
|
|
||||||
if (!m) return
|
|
||||||
|
|
||||||
const guardKey = lower(m.modelKey || m.id || '')
|
|
||||||
if (!guardKey) return
|
|
||||||
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
|
||||||
flagsInFlightRef.current[guardKey] = true
|
|
||||||
|
|
||||||
const prev = m
|
|
||||||
const nextFav = !Boolean(prev.favorite)
|
|
||||||
|
|
||||||
const optimistic: StoredModel = {
|
|
||||||
...prev,
|
|
||||||
favorite: nextFav,
|
|
||||||
liked: nextFav ? false : prev.liked,
|
|
||||||
}
|
|
||||||
|
|
||||||
const kPrev = lower(prev.modelKey || '')
|
|
||||||
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
|
|
||||||
upsertModelCache(optimistic)
|
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await patchModelFlags({
|
|
||||||
id: prev.id,
|
|
||||||
favorite: nextFav,
|
|
||||||
...(nextFav ? { liked: false } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
setModelsByKey((p) => {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (!k) return p
|
|
||||||
const { [k]: _drop, ...rest } = p
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
removeModelCache(prev.id)
|
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const k = lower(updated.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
|
||||||
upsertModelCache(updated)
|
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
|
||||||
} catch (e: any) {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
|
|
||||||
upsertModelCache(prev)
|
upsertModelCache(prev)
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
if (sameAsPlayer) setPlayerModel(prev)
|
||||||
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
|
||||||
@ -2613,7 +2650,7 @@ export default function App() {
|
|||||||
delete flagsInFlightRef.current[guardKey]
|
delete flagsInFlightRef.current[guardKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -2623,30 +2660,23 @@ export default function App() {
|
|||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||||
|
|
||||||
const hostFromJob = (j: RecordJob): string => {
|
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||||||
try {
|
if (!modelKey) {
|
||||||
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
|
notify.error('Like umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||||||
const u = extractFirstUrl(urlFromJob)
|
return
|
||||||
if (!u) return ''
|
|
||||||
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
|
const guardKey = modelKey
|
||||||
if (keyFromFile) {
|
|
||||||
const guardKey = keyFromFile
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
if (flagsInFlightRef.current[guardKey]) return
|
||||||
flagsInFlightRef.current[guardKey] = true
|
flagsInFlightRef.current[guardKey] = true
|
||||||
|
|
||||||
const prev =
|
const prev =
|
||||||
modelsByKey[keyFromFile] ??
|
modelsByKey[modelKey] ??
|
||||||
({
|
({
|
||||||
id: '',
|
id: '',
|
||||||
input: '',
|
input: '',
|
||||||
host: hostFromJob(job) || undefined,
|
host: host || undefined,
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
watching: false,
|
watching: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
liked: null,
|
liked: null,
|
||||||
@ -2657,107 +2687,45 @@ export default function App() {
|
|||||||
|
|
||||||
const optimistic: StoredModel = {
|
const optimistic: StoredModel = {
|
||||||
...prev,
|
...prev,
|
||||||
modelKey: prev.modelKey || keyFromFile,
|
host: prev.host || host || undefined,
|
||||||
|
modelKey,
|
||||||
liked: nextLiked,
|
liked: nextLiked,
|
||||||
favorite: nextLiked ? false : prev.favorite,
|
favorite: nextLiked ? false : prev.favorite,
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
|
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||||||
upsertModelCache(optimistic)
|
upsertModelCache(optimistic)
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
if (sameAsPlayer) setPlayerModel(optimistic)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await patchModelFlags({
|
const updated = await patchModelFlags({
|
||||||
...(optimistic.id ? { id: optimistic.id } : {}),
|
...(prev.id ? { id: prev.id } : {}),
|
||||||
host: optimistic.host || hostFromJob(job) || '',
|
host: optimistic.host || '',
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
liked: nextLiked,
|
liked: nextLiked,
|
||||||
...(nextLiked ? { favorite: false } : {}),
|
...(nextLiked ? { favorite: false } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
setModelsByKey((p) => {
|
setModelsByKey((p) => {
|
||||||
const { [keyFromFile]: _drop, ...rest } = p
|
const { [modelKey]: _drop, ...rest } = p
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
if (prev.id) removeModelCache(prev.id)
|
if (prev.id) removeModelCache(prev.id)
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
if (sameAsPlayer) setPlayerModel(null)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } })
|
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = lower(updated.modelKey || keyFromFile)
|
const k = lower(updated.modelKey || modelKey)
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
||||||
upsertModelCache(updated)
|
upsertModelCache(updated)
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
if (sameAsPlayer) setPlayerModel(updated)
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: prev }))
|
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||||||
upsertModelCache(prev)
|
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
|
||||||
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
|
|
||||||
} finally {
|
|
||||||
delete flagsInFlightRef.current[guardKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback
|
|
||||||
let m = sameAsPlayer ? playerModel : null
|
|
||||||
if (!m) m = await resolveModelForJob(job, { ensure: true })
|
|
||||||
if (!m) return
|
|
||||||
|
|
||||||
const guardKey = lower(m.modelKey || m.id || '')
|
|
||||||
if (!guardKey) return
|
|
||||||
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
|
||||||
flagsInFlightRef.current[guardKey] = true
|
|
||||||
|
|
||||||
const prev = m
|
|
||||||
const nextLiked = !(prev.liked === true)
|
|
||||||
|
|
||||||
const optimistic: StoredModel = {
|
|
||||||
...prev,
|
|
||||||
liked: nextLiked,
|
|
||||||
favorite: nextLiked ? false : prev.favorite,
|
|
||||||
}
|
|
||||||
|
|
||||||
const kPrev = lower(prev.modelKey || '')
|
|
||||||
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
|
|
||||||
upsertModelCache(optimistic)
|
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = nextLiked
|
|
||||||
? await patchModelFlags({ id: prev.id, liked: true, favorite: false })
|
|
||||||
: await patchModelFlags({ id: prev.id, liked: false })
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
setModelsByKey((p) => {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (!k) return p
|
|
||||||
const { [k]: _drop, ...rest } = p
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
removeModelCache(prev.id)
|
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const k = lower(updated.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
|
||||||
upsertModelCache(updated)
|
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
|
||||||
} catch (e: any) {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
|
|
||||||
upsertModelCache(prev)
|
upsertModelCache(prev)
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
if (sameAsPlayer) setPlayerModel(prev)
|
||||||
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
|
||||||
@ -2765,7 +2733,7 @@ export default function App() {
|
|||||||
delete flagsInFlightRef.current[guardKey]
|
delete flagsInFlightRef.current[guardKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -2775,30 +2743,23 @@ export default function App() {
|
|||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||||||
|
|
||||||
const hostFromJob = (j: RecordJob): string => {
|
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||||||
try {
|
if (!modelKey) {
|
||||||
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
|
notify.error('Watched umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||||||
const u = extractFirstUrl(urlFromJob)
|
return
|
||||||
if (!u) return ''
|
|
||||||
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
|
const guardKey = modelKey
|
||||||
if (keyFromFile) {
|
|
||||||
const guardKey = keyFromFile
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
if (flagsInFlightRef.current[guardKey]) return
|
||||||
flagsInFlightRef.current[guardKey] = true
|
flagsInFlightRef.current[guardKey] = true
|
||||||
|
|
||||||
const prev =
|
const prev =
|
||||||
modelsByKey[keyFromFile] ??
|
modelsByKey[modelKey] ??
|
||||||
({
|
({
|
||||||
id: '',
|
id: '',
|
||||||
input: '',
|
input: '',
|
||||||
host: hostFromJob(job) || undefined,
|
host: host || undefined,
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
watching: false,
|
watching: false,
|
||||||
favorite: false,
|
favorite: false,
|
||||||
liked: null,
|
liked: null,
|
||||||
@ -2809,102 +2770,43 @@ export default function App() {
|
|||||||
|
|
||||||
const optimistic: StoredModel = {
|
const optimistic: StoredModel = {
|
||||||
...prev,
|
...prev,
|
||||||
modelKey: prev.modelKey || keyFromFile,
|
host: prev.host || host || undefined,
|
||||||
|
modelKey,
|
||||||
watching: nextWatching,
|
watching: nextWatching,
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
|
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||||||
upsertModelCache(optimistic)
|
upsertModelCache(optimistic)
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
if (sameAsPlayer) setPlayerModel(optimistic)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await patchModelFlags({
|
const updated = await patchModelFlags({
|
||||||
...(optimistic.id ? { id: optimistic.id } : {}),
|
...(prev.id ? { id: prev.id } : {}),
|
||||||
host: optimistic.host || hostFromJob(job) || '',
|
host: optimistic.host || '',
|
||||||
modelKey: keyFromFile,
|
modelKey,
|
||||||
watched: nextWatching, // ✅ API key watched => watching in DB
|
watched: nextWatching,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
setModelsByKey((p) => {
|
setModelsByKey((p) => {
|
||||||
const { [keyFromFile]: _drop, ...rest } = p
|
const { [modelKey]: _drop, ...rest } = p
|
||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
if (prev.id) removeModelCache(prev.id)
|
if (prev.id) removeModelCache(prev.id)
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
if (sameAsPlayer) setPlayerModel(null)
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } })
|
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = lower(updated.modelKey || keyFromFile)
|
const k = lower(updated.modelKey || modelKey)
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
||||||
upsertModelCache(updated)
|
upsertModelCache(updated)
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
if (sameAsPlayer) setPlayerModel(updated)
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setModelsByKey((p) => ({ ...p, [keyFromFile]: prev }))
|
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||||||
upsertModelCache(prev)
|
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
|
||||||
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
|
|
||||||
} finally {
|
|
||||||
delete flagsInFlightRef.current[guardKey]
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback
|
|
||||||
let m = sameAsPlayer ? playerModel : null
|
|
||||||
if (!m) m = await resolveModelForJob(job, { ensure: true })
|
|
||||||
if (!m) return
|
|
||||||
|
|
||||||
const guardKey = lower(m.modelKey || m.id || '')
|
|
||||||
if (!guardKey) return
|
|
||||||
|
|
||||||
if (flagsInFlightRef.current[guardKey]) return
|
|
||||||
flagsInFlightRef.current[guardKey] = true
|
|
||||||
|
|
||||||
const prev = m
|
|
||||||
const nextWatching = !Boolean(prev.watching)
|
|
||||||
|
|
||||||
const optimistic: StoredModel = {
|
|
||||||
...prev,
|
|
||||||
watching: nextWatching,
|
|
||||||
}
|
|
||||||
|
|
||||||
const kPrev = lower(prev.modelKey || '')
|
|
||||||
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
|
|
||||||
upsertModelCache(optimistic)
|
|
||||||
if (sameAsPlayer) setPlayerModel(optimistic)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await patchModelFlags({ id: prev.id, watched: nextWatching })
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
setModelsByKey((p) => {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (!k) return p
|
|
||||||
const { [k]: _drop, ...rest } = p
|
|
||||||
return rest
|
|
||||||
})
|
|
||||||
removeModelCache(prev.id)
|
|
||||||
if (sameAsPlayer) setPlayerModel(null)
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const k = lower(updated.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
|
|
||||||
upsertModelCache(updated)
|
|
||||||
if (sameAsPlayer) setPlayerModel(updated)
|
|
||||||
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
|
|
||||||
} catch (e: any) {
|
|
||||||
const k = lower(prev.modelKey || '')
|
|
||||||
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
|
|
||||||
upsertModelCache(prev)
|
upsertModelCache(prev)
|
||||||
if (sameAsPlayer) setPlayerModel(prev)
|
if (sameAsPlayer) setPlayerModel(prev)
|
||||||
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
|
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
|
||||||
@ -2912,120 +2814,9 @@ export default function App() {
|
|||||||
delete flagsInFlightRef.current[guardKey]
|
delete flagsInFlightRef.current[guardKey]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||||||
)
|
)
|
||||||
|
|
||||||
async function resolveModelForJob(
|
|
||||||
job: RecordJob,
|
|
||||||
opts?: { ensure?: boolean }
|
|
||||||
): Promise<StoredModel | null> {
|
|
||||||
const wantEnsure = Boolean(opts?.ensure)
|
|
||||||
|
|
||||||
const upsertCache = (m: StoredModel) => {
|
|
||||||
const now = Date.now()
|
|
||||||
const cur = modelsCacheRef.current
|
|
||||||
if (!cur) {
|
|
||||||
modelsCacheRef.current = { ts: now, list: [m] }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cur.ts = now
|
|
||||||
const idx = cur.list.findIndex((x) => x.id === m.id)
|
|
||||||
if (idx >= 0) cur.list[idx] = m
|
|
||||||
else cur.list.unshift(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveByKey = async (key: string): Promise<StoredModel | null> => {
|
|
||||||
if (!key) return null
|
|
||||||
|
|
||||||
const needle = key.trim().toLowerCase()
|
|
||||||
if (!needle) return null
|
|
||||||
|
|
||||||
// ✅ 0) Sofort aus App-State (schnellster Pfad)
|
|
||||||
const stateHit = modelsByKey[needle]
|
|
||||||
if (stateHit) {
|
|
||||||
upsertCache(stateHit)
|
|
||||||
return stateHit
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models)
|
|
||||||
if (wantEnsure) {
|
|
||||||
let host: string | undefined
|
|
||||||
|
|
||||||
// versuche Host aus sourceUrl zu nehmen (falls vorhanden)
|
|
||||||
try {
|
|
||||||
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
|
|
||||||
const url = extractFirstUrl(urlFromJob)
|
|
||||||
if (url) host = new URL(url).hostname.replace(/^www\./i, '').toLowerCase()
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const ensured = await apiJSON<StoredModel>('/api/models/ensure', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ modelKey: key, ...(host ? { host } : {}) }),
|
|
||||||
})
|
|
||||||
upsertModelCache(ensured)
|
|
||||||
return ensured
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 2) Cache refreshen / initial füllen (nur wenn KEIN ensure)
|
|
||||||
const now = Date.now()
|
|
||||||
const cached = modelsCacheRef.current
|
|
||||||
|
|
||||||
if (!cached || now - cached.ts > 30_000) {
|
|
||||||
// erst aus State seeden (falls vorhanden)
|
|
||||||
const seeded = Object.values(modelsByKey)
|
|
||||||
if (seeded.length) {
|
|
||||||
modelsCacheRef.current = { ts: now, list: seeded }
|
|
||||||
} else {
|
|
||||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
|
|
||||||
modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = modelsCacheRef.current?.list ?? []
|
|
||||||
|
|
||||||
// ✅ 3) im Cache suchen
|
|
||||||
const hit = list.find((m) => (m.modelKey || '').trim().toLowerCase() === needle)
|
|
||||||
if (hit) return hit
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyFromFile = modelKeyFromFilename(job.output || '')
|
|
||||||
|
|
||||||
// ✅ Wichtig: IMMER zuerst Dateiname (auch bei running)
|
|
||||||
// -> spart /parse + /upsert und macht Toggle instant
|
|
||||||
if (keyFromFile) {
|
|
||||||
return resolveByKey(keyFromFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRunning = job.status === 'running'
|
|
||||||
|
|
||||||
// ✅ Nur wenn wir wirklich KEINEN Key aus dem Output haben:
|
|
||||||
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
|
|
||||||
const url = extractFirstUrl(urlFromJob)
|
|
||||||
|
|
||||||
if (isRunning && url) {
|
|
||||||
const parsed = await apiJSON<any>('/api/models/parse', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ input: url }),
|
|
||||||
})
|
|
||||||
|
|
||||||
const saved = await apiJSON<StoredModel>('/api/models/upsert', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(parsed),
|
|
||||||
})
|
|
||||||
|
|
||||||
upsertModelCache(saved)
|
|
||||||
return saved
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: wenn gar nix geht
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clipboard auto add/start (wie bei dir)
|
// Clipboard auto add/start (wie bei dir)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoAddEnabled && !autoStartEnabled) return
|
if (!autoAddEnabled && !autoStartEnabled) return
|
||||||
@ -3287,6 +3078,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
onOpenPlayer={openPlayer}
|
onOpenPlayer={openPlayer}
|
||||||
onStopJob={stopJob}
|
onStopJob={stopJob}
|
||||||
|
onRemoveQueuedPostworkJob={removeQueuedPostworkJob}
|
||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
|
|||||||
@ -35,10 +35,19 @@ type Props = {
|
|||||||
pending?: PendingWatchedRoom[]
|
pending?: PendingWatchedRoom[]
|
||||||
autostartState?: AutostartState
|
autostartState?: AutostartState
|
||||||
onRefreshAutostartState?: () => Promise<void> | void
|
onRefreshAutostartState?: () => Promise<void> | void
|
||||||
modelsByKey?: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
|
modelsByKey?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
favorite?: boolean
|
||||||
|
liked?: boolean | null
|
||||||
|
watching?: boolean
|
||||||
|
roomStatus?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
roomStatusByModelKey?: Record<string, string>
|
roomStatusByModelKey?: Record<string, string>
|
||||||
onOpenPlayer: (job: RecordJob) => void
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
onStopJob: (id: string) => void
|
onStopJob: (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>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
@ -116,7 +125,8 @@ const modelKeyFromJob = (job: RecordJob): string => {
|
|||||||
|
|
||||||
const roomStatusOfJob = (
|
const roomStatusOfJob = (
|
||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
roomStatusByModelKey?: Record<string, string>
|
roomStatusByModelKey?: Record<string, string>,
|
||||||
|
modelsByKey?: Record<string, { roomStatus?: string }>
|
||||||
): string => {
|
): string => {
|
||||||
const j = job as any
|
const j = job as any
|
||||||
|
|
||||||
@ -142,9 +152,38 @@ const roomStatusOfJob = (
|
|||||||
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
|
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modelKey && modelsByKey?.[modelKey]?.roomStatus) {
|
||||||
|
return normalizeRoomStatus(modelsByKey[modelKey].roomStatus)
|
||||||
|
}
|
||||||
|
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function effectiveRoomStatusOfJob(
|
||||||
|
job: RecordJob,
|
||||||
|
roomStatusByModelKey?: Record<string, string>,
|
||||||
|
modelsByKey?: Record<string, { roomStatus?: string }>,
|
||||||
|
growingByJobId?: Record<string, boolean>
|
||||||
|
): string {
|
||||||
|
const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey)
|
||||||
|
if (raw !== 'Unknown') return raw
|
||||||
|
|
||||||
|
const anyJ = job as any
|
||||||
|
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
|
||||||
|
const statusLower = String(job.status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const isActiveRecording =
|
||||||
|
!job.endedAt &&
|
||||||
|
statusLower === 'running' &&
|
||||||
|
(phaseLower === '' || phaseLower === 'recording')
|
||||||
|
|
||||||
|
if (isActiveRecording && growingByJobId?.[job.id]) {
|
||||||
|
return 'Public'
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
const roomStatusTone = (status: string): string => {
|
const roomStatusTone = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Public':
|
case 'Public':
|
||||||
@ -260,24 +299,24 @@ function postWorkLabel(
|
|||||||
job: RecordJob,
|
job: RecordJob,
|
||||||
override?: { pos?: number; total?: number }
|
override?: { pos?: number; total?: number }
|
||||||
): string {
|
): string {
|
||||||
const pw = (job as any).postWork
|
const anyJ = job as any
|
||||||
|
const pw = anyJ.postWork
|
||||||
|
|
||||||
if (!pw) return 'Warte auf Nacharbeiten…'
|
const effectiveState = getEffectivePostworkState(job)
|
||||||
|
|
||||||
|
if (effectiveState === 'running') {
|
||||||
|
const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1
|
||||||
|
const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0
|
||||||
|
|
||||||
if (pw.state === 'running') {
|
|
||||||
const running = typeof pw.running === 'number' ? pw.running : 0
|
|
||||||
const maxP = typeof pw.maxParallel === 'number' ? pw.maxParallel : 0
|
|
||||||
return maxP > 0
|
return maxP > 0
|
||||||
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
|
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
|
||||||
: 'Nacharbeiten laufen…'
|
: 'Nacharbeiten laufen…'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pw.state === 'queued') {
|
if (effectiveState === 'queued') {
|
||||||
// Backend-Werte (können was anderes zählen -> deshalb nur Fallback)
|
const posServer = typeof pw?.position === 'number' ? pw.position : 0
|
||||||
const posServer = typeof pw.position === 'number' ? pw.position : 0
|
const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0
|
||||||
const waitingServer = typeof pw.waiting === 'number' ? pw.waiting : 0
|
const totalServer = Math.max(waitingServer, posServer)
|
||||||
const runningServer = typeof (pw as any).running === 'number' ? (pw as any).running : 0
|
|
||||||
const totalServer = Math.max(waitingServer + runningServer, posServer)
|
|
||||||
|
|
||||||
const pos =
|
const pos =
|
||||||
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
|
typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0
|
||||||
@ -304,26 +343,38 @@ function StatusCell({
|
|||||||
job: RecordJob
|
job: RecordJob
|
||||||
postworkInfo?: { pos?: number; total?: number }
|
postworkInfo?: { pos?: number; total?: number }
|
||||||
}) {
|
}) {
|
||||||
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
const anyJ = job as any
|
||||||
const progress = Number((job as any)?.progress ?? 0)
|
const phaseRaw = String(anyJ?.phase ?? '').trim()
|
||||||
|
|
||||||
const phase = phaseRaw.toLowerCase()
|
const phase = phaseRaw.toLowerCase()
|
||||||
|
const progress = Number(anyJ?.progress ?? 0)
|
||||||
|
|
||||||
const isRecording = phase === 'recording'
|
const isRecording = phase === 'recording'
|
||||||
|
const isPwJob = isPostworkJob(job)
|
||||||
|
const pwState = getEffectivePostworkState(job)
|
||||||
|
|
||||||
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
|
let text = ''
|
||||||
|
|
||||||
// ✅ postwork genauer machen (wartend/running + Position)
|
|
||||||
if (phase === 'postwork') {
|
|
||||||
phaseText = postWorkLabel(job, postworkInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRecording) {
|
if (isRecording) {
|
||||||
phaseText = 'Recording läuft…'
|
text = 'Recording läuft…'
|
||||||
|
} else if (isPwJob) {
|
||||||
|
// 1) Konkrete Postwork-Unterphasen IMMER bevorzugen
|
||||||
|
if (phase === 'probe' || phase === 'remuxing' || phase === 'moving' || phase === 'assets') {
|
||||||
|
text = phaseLabel(phaseRaw) || phaseRaw
|
||||||
|
}
|
||||||
|
// 2) Queue-Fall
|
||||||
|
else if (pwState === 'queued') {
|
||||||
|
text = postWorkLabel(job, postworkInfo)
|
||||||
|
}
|
||||||
|
// 3) Generischer laufender Postwork-Fall
|
||||||
|
else if (pwState === 'running') {
|
||||||
|
text = phaseLabel(phaseRaw) || postWorkLabel(job, postworkInfo)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
|
if (!text) {
|
||||||
|
text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Balken NICHT während recording anzeigen
|
|
||||||
const showBar =
|
const showBar =
|
||||||
!isRecording &&
|
!isRecording &&
|
||||||
Number.isFinite(progress) &&
|
Number.isFinite(progress) &&
|
||||||
@ -333,7 +384,7 @@ function StatusCell({
|
|||||||
const showIndeterminate =
|
const showIndeterminate =
|
||||||
!isRecording &&
|
!isRecording &&
|
||||||
!showBar &&
|
!showBar &&
|
||||||
Boolean(phaseRaw) &&
|
Boolean(text) &&
|
||||||
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -369,10 +420,12 @@ function DownloadsCardRow({
|
|||||||
modelsByKey,
|
modelsByKey,
|
||||||
roomStatusByModelKey,
|
roomStatusByModelKey,
|
||||||
stopRequestedIds,
|
stopRequestedIds,
|
||||||
|
growingByJobId,
|
||||||
postworkInfoOf,
|
postworkInfoOf,
|
||||||
markStopRequested,
|
markStopRequested,
|
||||||
onOpenPlayer,
|
onOpenPlayer,
|
||||||
onStopJob,
|
onStopJob,
|
||||||
|
onRemoveQueuedPostworkJob,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
@ -380,13 +433,23 @@ function DownloadsCardRow({
|
|||||||
r: DownloadRow
|
r: DownloadRow
|
||||||
nowMs: number
|
nowMs: number
|
||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean }>
|
modelsByKey: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
favorite?: boolean
|
||||||
|
liked?: boolean | null
|
||||||
|
watching?: boolean
|
||||||
|
roomStatus?: string
|
||||||
|
}
|
||||||
|
>
|
||||||
roomStatusByModelKey: Record<string, string>
|
roomStatusByModelKey: Record<string, string>
|
||||||
stopRequestedIds: Record<string, true>
|
stopRequestedIds: Record<string, true>
|
||||||
|
growingByJobId: Record<string, boolean>
|
||||||
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
|
postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined
|
||||||
markStopRequested: (ids: string | string[]) => void
|
markStopRequested: (ids: string | string[]) => void
|
||||||
onOpenPlayer: (job: RecordJob) => void
|
onOpenPlayer: (job: RecordJob) => void
|
||||||
onStopJob: (id: string) => void
|
onStopJob: (id: string) => void | Promise<void>
|
||||||
|
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
|
||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
@ -485,24 +548,45 @@ function DownloadsCardRow({
|
|||||||
const file = baseName(j.output || '')
|
const file = baseName(j.output || '')
|
||||||
|
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
const phase = String((j as any).phase ?? '').trim()
|
||||||
|
|
||||||
const phaseLower = phase.toLowerCase()
|
const phaseLower = phase.toLowerCase()
|
||||||
const isRecording = phaseLower === 'recording'
|
const isRecording = phaseLower === 'recording'
|
||||||
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur für Stop-Button/UI
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
const rawStatus = String(j.status ?? '').toLowerCase()
|
||||||
|
|
||||||
|
const postworkState = getEffectivePostworkState(j)
|
||||||
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
||||||
|
|
||||||
const roomStatus = roomStatusOfJob(j, roomStatusByModelKey)
|
const showRemoveQueuedButton = isQueuedPostwork
|
||||||
|
const disableStopButton = showRemoveQueuedButton ? false : isStopping
|
||||||
|
const actionsBusy = showRemoveQueuedButton ? false : isStopping
|
||||||
|
|
||||||
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
const roomStatus = effectiveRoomStatusOfJob(
|
||||||
|
j,
|
||||||
|
roomStatusByModelKey,
|
||||||
|
modelsByKey,
|
||||||
|
growingByJobId
|
||||||
|
)
|
||||||
|
|
||||||
|
let phaseText = ''
|
||||||
|
|
||||||
if (phaseLower === 'recording') {
|
if (phaseLower === 'recording') {
|
||||||
phaseText = 'Recording läuft…'
|
phaseText = 'Recording läuft…'
|
||||||
} else if (phaseLower === 'postwork') {
|
} else if (isPostworkJob(j)) {
|
||||||
|
const pwState = getEffectivePostworkState(j)
|
||||||
|
|
||||||
|
if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') {
|
||||||
|
phaseText = phaseLabel(phase) || phase
|
||||||
|
} else if (pwState === 'queued') {
|
||||||
phaseText = postWorkLabel(j, postworkInfoOf(j))
|
phaseText = postWorkLabel(j, postworkInfoOf(j))
|
||||||
|
} else if (pwState === 'running') {
|
||||||
|
phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressLabel = phaseText || roomStatus
|
const progressLabel = phaseText || roomStatus
|
||||||
@ -654,7 +738,7 @@ function DownloadsCardRow({
|
|||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={j}
|
job={j}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={isStopping}
|
busy={actionsBusy}
|
||||||
isFavorite={isFav}
|
isFavorite={isFav}
|
||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
@ -668,17 +752,23 @@ function DownloadsCardRow({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant={showRemoveQueuedButton ? 'secondary' : 'primary'}
|
||||||
disabled={isStopping}
|
disabled={disableStopButton}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (showRemoveQueuedButton) {
|
||||||
|
await onRemoveQueuedPostworkJob?.(j.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isStopping) return
|
if (isStopping) return
|
||||||
markStopRequested(j.id)
|
markStopRequested(j.id)
|
||||||
onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isStopping ? 'Stoppe…' : 'Stop'}
|
{showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -775,6 +865,61 @@ const isPostworkJob = (job: RecordJob): boolean => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => {
|
||||||
|
const anyJ = job as any
|
||||||
|
const phase = String(anyJ.phase ?? '').trim().toLowerCase()
|
||||||
|
const pw = anyJ.postWork
|
||||||
|
const pwState = String(pw?.state ?? '').trim().toLowerCase()
|
||||||
|
const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== ''
|
||||||
|
|
||||||
|
if (!isPostworkJob(job)) return 'none'
|
||||||
|
if (isTerminalStatus(anyJ?.status)) return 'none'
|
||||||
|
|
||||||
|
// 1) Harte Wahrheit aus postWork
|
||||||
|
if (pwState === 'queued') return 'queued'
|
||||||
|
if (pwState === 'running') return 'running'
|
||||||
|
|
||||||
|
// 2) Echte Arbeitsphasen schlagen alles andere
|
||||||
|
if (
|
||||||
|
phase === 'postwork' ||
|
||||||
|
phase === 'probe' ||
|
||||||
|
phase === 'remuxing' ||
|
||||||
|
phase === 'moving' ||
|
||||||
|
phase === 'assets'
|
||||||
|
) {
|
||||||
|
return 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Queue-Hinweise aus postWork
|
||||||
|
if (
|
||||||
|
typeof pw?.position === 'number' &&
|
||||||
|
Number.isFinite(pw.position) &&
|
||||||
|
pw.position > 0
|
||||||
|
) {
|
||||||
|
return 'queued'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof pw?.running === 'number' &&
|
||||||
|
Number.isFinite(pw.running) &&
|
||||||
|
pw.running > 0 &&
|
||||||
|
(!Number.isFinite(pw?.position) || pw.position <= 0)
|
||||||
|
) {
|
||||||
|
return 'running'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Wenn es einen PostWorkKey gibt, aber noch keine Running-Phase sichtbar ist,
|
||||||
|
// dann eher queued
|
||||||
|
if (hasPwKey) {
|
||||||
|
return 'queued'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Fallback für alte Jobs
|
||||||
|
if (anyJ.endedAt || job.endedAt) return 'queued'
|
||||||
|
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
|
||||||
const isTerminalStatus = (status?: unknown) => {
|
const isTerminalStatus = (status?: unknown) => {
|
||||||
const s = String(status ?? '').trim().toLowerCase()
|
const s = String(status ?? '').trim().toLowerCase()
|
||||||
return (
|
return (
|
||||||
@ -814,6 +959,7 @@ export default function Downloads({
|
|||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
onAddToDownloads,
|
onAddToDownloads,
|
||||||
|
onRemoveQueuedPostworkJob,
|
||||||
modelsByKey = {},
|
modelsByKey = {},
|
||||||
roomStatusByModelKey = {},
|
roomStatusByModelKey = {},
|
||||||
blurPreviews
|
blurPreviews
|
||||||
@ -841,6 +987,8 @@ export default function Downloads({
|
|||||||
}
|
}
|
||||||
}, [onRefreshAutostartState])
|
}, [onRefreshAutostartState])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPaused = Boolean(autostartState?.paused)
|
const nextPaused = Boolean(autostartState?.paused)
|
||||||
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
const nextPausedByUser = Boolean(autostartState?.pausedByUser)
|
||||||
@ -935,24 +1083,87 @@ export default function Downloads({
|
|||||||
for (const id of keys) {
|
for (const id of keys) {
|
||||||
const j = jobs.find((x) => x.id === id)
|
const j = jobs.find((x) => x.id === id)
|
||||||
if (!j) continue
|
if (!j) continue
|
||||||
|
|
||||||
|
const postworkState = getEffectivePostworkState(j)
|
||||||
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
if (isQueuedPostwork) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
const isStopping = isBusyPhase || j.status !== 'running'
|
const isStopping = isBusyPhase || j.status !== 'running'
|
||||||
|
|
||||||
if (!isStopping) next[id] = true
|
if (isStopping) {
|
||||||
|
next[id] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
|
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now())
|
const [nowMs, setNowMs] = useState(() => Date.now())
|
||||||
|
|
||||||
|
const prevSizeBytesRef = useRef<Record<string, number>>({})
|
||||||
|
const [growingByJobId, setGrowingByJobId] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const hasActive = useMemo(() => {
|
const hasActive = useMemo(() => {
|
||||||
// tickt solange mind. ein Job noch nicht beendet ist
|
// tickt solange mind. ein Job noch nicht beendet ist
|
||||||
return jobs.some((j) => !j.endedAt && j.status === 'running')
|
return jobs.some((j) => !j.endedAt && j.status === 'running')
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setGrowingByJobId((prev) => {
|
||||||
|
const next: Record<string, boolean> = { ...prev }
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
const id = String(job.id ?? '').trim()
|
||||||
|
if (!id) continue
|
||||||
|
|
||||||
|
seen.add(id)
|
||||||
|
|
||||||
|
const anyJ = job as any
|
||||||
|
const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase()
|
||||||
|
const statusLower = String(job.status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
|
const isActiveRecording =
|
||||||
|
!job.endedAt &&
|
||||||
|
statusLower === 'running' &&
|
||||||
|
(phaseLower === '' || phaseLower === 'recording')
|
||||||
|
|
||||||
|
const currentSize = sizeBytesOf(job) ?? 0
|
||||||
|
const prevSize = prevSizeBytesRef.current[id] ?? 0
|
||||||
|
|
||||||
|
const grewNow = currentSize > prevSize
|
||||||
|
const nextGrowing = isActiveRecording ? (prev[id] === true || grewNow) : false
|
||||||
|
|
||||||
|
if (next[id] !== nextGrowing) {
|
||||||
|
next[id] = nextGrowing
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
prevSizeBytesRef.current[id] = currentSize
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(prevSizeBytesRef.current)) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
delete prevSizeBytesRef.current[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(next)) {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
delete next[id]
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}, [jobs, nowMs])
|
||||||
|
|
||||||
const postworkQueueInfoById = useMemo(() => {
|
const postworkQueueInfoById = useMemo(() => {
|
||||||
const infoById = new Map<string, { pos: number; total: number }>()
|
const infoById = new Map<string, { pos: number; total: number }>()
|
||||||
|
|
||||||
@ -965,46 +1176,64 @@ export default function Downloads({
|
|||||||
toMs(anyJ.queuedAt) ||
|
toMs(anyJ.queuedAt) ||
|
||||||
toMs(anyJ.createdAt) ||
|
toMs(anyJ.createdAt) ||
|
||||||
toMs(anyJ.addedAt) ||
|
toMs(anyJ.addedAt) ||
|
||||||
toMs(job.endedAt) || // Postwork entsteht oft nach endedAt
|
toMs(job.endedAt) ||
|
||||||
toMs(job.startedAt) ||
|
toMs(job.startedAt) ||
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) alle relevanten Postwork-Jobs sammeln (queued + running)
|
const runningSortMsOf = (job: RecordJob): number => {
|
||||||
|
const anyJ = job as any
|
||||||
|
return (
|
||||||
|
toMs(anyJ.updatedAt) ||
|
||||||
|
toMs(anyJ.phaseUpdatedAt) ||
|
||||||
|
toMs(anyJ.progressUpdatedAt) ||
|
||||||
|
toMs(anyJ.postWork?.updatedAt) ||
|
||||||
|
enqueueMsOf(job)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const running: RecordJob[] = []
|
const running: RecordJob[] = []
|
||||||
const queued: RecordJob[] = []
|
const queued: RecordJob[] = []
|
||||||
|
|
||||||
for (const j of jobs) {
|
for (const j of jobs) {
|
||||||
const pw = (j as any)?.postWork
|
if (!isPostworkJob(j)) continue
|
||||||
if (!pw) continue
|
if (isTerminalStatus((j as any)?.status)) continue
|
||||||
|
|
||||||
const state = String(pw.state ?? '').toLowerCase()
|
const state = getEffectivePostworkState(j)
|
||||||
if (state === 'running') running.push(j)
|
|
||||||
else if (state === 'queued') queued.push(j)
|
if (state === 'running') {
|
||||||
|
running.push(j)
|
||||||
|
} else if (state === 'queued') {
|
||||||
|
queued.push(j)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Reihenfolge stabil machen (FIFO)
|
// aktuell bearbeitete Jobs zuerst
|
||||||
running.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b))
|
running.sort((a, b) => runningSortMsOf(b) - runningSortMsOf(a))
|
||||||
|
|
||||||
|
// wartende Jobs FIFO
|
||||||
queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b))
|
queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b))
|
||||||
|
|
||||||
const runningCount = running.length
|
const runningCount = running.length
|
||||||
const total = runningCount + queued.length
|
const queuedCount = queued.length
|
||||||
|
const total = runningCount + queuedCount
|
||||||
|
|
||||||
// 3) Positionen setzen: running belegt "vorne", queued danach
|
// running bekommt weiter Gesamtinfo
|
||||||
|
for (let i = 0; i < running.length; i++) {
|
||||||
|
const id = String((running[i] as any)?.id ?? '')
|
||||||
|
if (!id) continue
|
||||||
|
infoById.set(id, { pos: i + 1, total })
|
||||||
|
}
|
||||||
|
|
||||||
|
// queued als echte Warteschlangenposition:
|
||||||
|
// erster wartender Job = 1
|
||||||
for (let i = 0; i < queued.length; i++) {
|
for (let i = 0; i < queued.length; i++) {
|
||||||
const id = String((queued[i] as any)?.id ?? '')
|
const id = String((queued[i] as any)?.id ?? '')
|
||||||
if (!id) continue
|
if (!id) continue
|
||||||
infoById.set(id, { pos: runningCount + i + 1, total })
|
infoById.set(id, { pos: i + 1, total: queuedCount })
|
||||||
}
|
}
|
||||||
|
|
||||||
// optional (wenn du auch bei running "x / total" sehen willst):
|
|
||||||
// for (let i = 0; i < running.length; i++) {
|
|
||||||
// const id = String((running[i] as any)?.id ?? '')
|
|
||||||
// if (!id) continue
|
|
||||||
// infoById.set(id, { pos: i + 1, total })
|
|
||||||
// }
|
|
||||||
|
|
||||||
return infoById
|
return infoById
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
@ -1052,7 +1281,12 @@ export default function Downloads({
|
|||||||
<ModelPreview
|
<ModelPreview
|
||||||
jobId={j.id}
|
jobId={j.id}
|
||||||
blur={blurPreviews}
|
blur={blurPreviews}
|
||||||
roomStatus={roomStatusOfJob(j, roomStatusByModelKey)}
|
roomStatus={effectiveRoomStatusOfJob(
|
||||||
|
j,
|
||||||
|
roomStatusByModelKey,
|
||||||
|
modelsByKey,
|
||||||
|
growingByJobId
|
||||||
|
)}
|
||||||
alignStartAt={j.startedAt}
|
alignStartAt={j.startedAt}
|
||||||
alignEndAt={j.endedAt ?? null}
|
alignEndAt={j.endedAt ?? null}
|
||||||
alignEveryMs={10_000}
|
alignEveryMs={10_000}
|
||||||
@ -1108,7 +1342,12 @@ export default function Downloads({
|
|||||||
const f = baseName(j.output || '')
|
const f = baseName(j.output || '')
|
||||||
const name = modelNameFromOutput(j.output)
|
const name = modelNameFromOutput(j.output)
|
||||||
|
|
||||||
const roomStatus = roomStatusOfJob(j, roomStatusByModelKey)
|
const roomStatus = effectiveRoomStatusOfJob(
|
||||||
|
j,
|
||||||
|
roomStatusByModelKey,
|
||||||
|
modelsByKey,
|
||||||
|
growingByJobId
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1247,9 +1486,14 @@ export default function Downloads({
|
|||||||
const phase = String((j as any).phase ?? '').trim()
|
const phase = String((j as any).phase ?? '').trim()
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
||||||
const phaseLower = phase.trim().toLowerCase()
|
const phaseLower = phase.trim().toLowerCase()
|
||||||
|
|
||||||
|
const postworkState = getEffectivePostworkState(j)
|
||||||
|
const isQueuedPostwork = postworkState === 'queued'
|
||||||
|
|
||||||
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
|
||||||
|
const disableStopButton = isQueuedPostwork ? false : isStopping
|
||||||
|
const actionsBusy = isQueuedPostwork ? false : isStopping
|
||||||
|
|
||||||
const key = modelNameFromOutput(j.output || '')
|
const key = modelNameFromOutput(j.output || '')
|
||||||
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
|
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
|
||||||
@ -1263,7 +1507,7 @@ export default function Downloads({
|
|||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={j}
|
job={j}
|
||||||
variant="table"
|
variant="table"
|
||||||
busy={isStopping}
|
busy={actionsBusy}
|
||||||
isFavorite={isFav}
|
isFavorite={isFav}
|
||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
@ -1286,16 +1530,23 @@ export default function Downloads({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
disabled={isStopping}
|
color={isQueuedPostwork ? 'red' : 'indigo'}
|
||||||
|
disabled={disableStopButton}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
onClick={(e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (isQueuedPostwork) {
|
||||||
|
await onRemoveQueuedPostworkJob?.(j.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isStopping) return
|
if (isStopping) return
|
||||||
markStopRequested(j.id)
|
markStopRequested(j.id)
|
||||||
onStopJob(j.id)
|
await onStopJob(j.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isStopping ? 'Stoppe…' : 'Stop'}
|
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@ -1304,7 +1555,7 @@ export default function Downloads({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
|
}, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, growingByJobId, nowMs, onStopJob, onRemoveQueuedPostworkJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
|
||||||
|
|
||||||
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
||||||
const list = jobs
|
const list = jobs
|
||||||
@ -1335,10 +1586,8 @@ export default function Downloads({
|
|||||||
.map((job) => ({ kind: 'job', job }) as const)
|
.map((job) => ({ kind: 'job', job }) as const)
|
||||||
|
|
||||||
const stateRank = (j: RecordJob): number => {
|
const stateRank = (j: RecordJob): number => {
|
||||||
const pw = (j as any)?.postWork
|
const state = getEffectivePostworkState(j)
|
||||||
const state = String(pw?.state ?? '').toLowerCase()
|
|
||||||
|
|
||||||
// running zuerst anzeigen, queued danach
|
|
||||||
if (state === 'running') return 0
|
if (state === 'running') return 0
|
||||||
if (state === 'queued') return 1
|
if (state === 'queued') return 1
|
||||||
return 2
|
return 2
|
||||||
@ -1350,6 +1599,17 @@ export default function Downloads({
|
|||||||
return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER
|
return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runningSortMsOf = (j: RecordJob): number => {
|
||||||
|
const anyJ = j as any
|
||||||
|
return (
|
||||||
|
toMs(anyJ.updatedAt) ||
|
||||||
|
toMs(anyJ.phaseUpdatedAt) ||
|
||||||
|
toMs(anyJ.progressUpdatedAt) ||
|
||||||
|
toMs(anyJ.postWork?.updatedAt) ||
|
||||||
|
addedAtMsOf({ kind: 'job', job: j })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const aj = a.job
|
const aj = a.job
|
||||||
const bj = b.job
|
const bj = b.job
|
||||||
@ -1358,12 +1618,19 @@ export default function Downloads({
|
|||||||
const sr = stateRank(aj) - stateRank(bj)
|
const sr = stateRank(aj) - stateRank(bj)
|
||||||
if (sr !== 0) return sr
|
if (sr !== 0) return sr
|
||||||
|
|
||||||
// 2) queued nach Position in der Queue sortieren (1, 2, 3, ...)
|
// 2) innerhalb running: der aktuell bearbeitete nach oben
|
||||||
|
if (getEffectivePostworkState(aj) === 'running' && getEffectivePostworkState(bj) === 'running') {
|
||||||
|
const ra = runningSortMsOf(aj)
|
||||||
|
const rb = runningSortMsOf(bj)
|
||||||
|
if (ra !== rb) return rb - ra
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) queued nach Position in der Queue sortieren
|
||||||
const pa = queuePosOf(aj)
|
const pa = queuePosOf(aj)
|
||||||
const pb = queuePosOf(bj)
|
const pb = queuePosOf(bj)
|
||||||
if (pa !== pb) return pa - pb
|
if (pa !== pb) return pa - pb
|
||||||
|
|
||||||
// 3) Fallback stabil nach enqueue/addedAt (älter zuerst)
|
// 4) Fallback stabil nach enqueue/addedAt
|
||||||
return addedAtMsOf(a) - addedAtMsOf(b)
|
return addedAtMsOf(a) - addedAtMsOf(b)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1486,9 +1753,11 @@ export default function Downloads({
|
|||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
postworkInfoOf={postworkInfoOf}
|
postworkInfoOf={postworkInfoOf}
|
||||||
stopRequestedIds={stopRequestedIds}
|
stopRequestedIds={stopRequestedIds}
|
||||||
|
growingByJobId={growingByJobId}
|
||||||
markStopRequested={markStopRequested}
|
markStopRequested={markStopRequested}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={onOpenPlayer}
|
||||||
onStopJob={onStopJob}
|
onStopJob={onStopJob}
|
||||||
|
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
@ -1512,9 +1781,11 @@ export default function Downloads({
|
|||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
postworkInfoOf={postworkInfoOf}
|
postworkInfoOf={postworkInfoOf}
|
||||||
stopRequestedIds={stopRequestedIds}
|
stopRequestedIds={stopRequestedIds}
|
||||||
|
growingByJobId={growingByJobId}
|
||||||
markStopRequested={markStopRequested}
|
markStopRequested={markStopRequested}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={onOpenPlayer}
|
||||||
onStopJob={onStopJob}
|
onStopJob={onStopJob}
|
||||||
|
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
@ -1538,9 +1809,11 @@ export default function Downloads({
|
|||||||
roomStatusByModelKey={roomStatusByModelKey}
|
roomStatusByModelKey={roomStatusByModelKey}
|
||||||
postworkInfoOf={postworkInfoOf}
|
postworkInfoOf={postworkInfoOf}
|
||||||
stopRequestedIds={stopRequestedIds}
|
stopRequestedIds={stopRequestedIds}
|
||||||
|
growingByJobId={growingByJobId}
|
||||||
markStopRequested={markStopRequested}
|
markStopRequested={markStopRequested}
|
||||||
onOpenPlayer={onOpenPlayer}
|
onOpenPlayer={onOpenPlayer}
|
||||||
onStopJob={onStopJob}
|
onStopJob={onStopJob}
|
||||||
|
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
|
|||||||
@ -227,6 +227,120 @@ function PromoteToFrontWrapper({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoadingCardSkeleton({ blurred = false }: { blurred?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'group relative overflow-hidden rounded-lg outline-1 outline-black/5 dark:-outline-offset-1 dark:outline-white/10',
|
||||||
|
'bg-white dark:bg-gray-900/40',
|
||||||
|
blurred ? 'blur-[1.5px] saturate-90 scale-[0.995]' : '',
|
||||||
|
].join(' ')}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="relative aspect-video rounded-t-lg bg-gray-200/80 dark:bg-white/10">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/20 via-transparent to-black/5 dark:from-white/5 dark:to-white/10" />
|
||||||
|
|
||||||
|
{/* Meta-Overlay unten rechts */}
|
||||||
|
<div className="absolute right-2 bottom-2 z-10">
|
||||||
|
<div className="h-4 w-28 rounded bg-black/25 dark:bg-black/35" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer / Meta */}
|
||||||
|
<div className="relative min-h-[112px] overflow-hidden border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="mt-2 h-3 w-48 rounded bg-gray-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 flex items-center gap-1.5 pt-0.5">
|
||||||
|
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="size-4 rounded-full bg-gray-200 dark:bg-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 rounded-md bg-gray-50/70 p-1 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-8 flex-1 rounded bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
|
||||||
|
<div className="h-8 w-10 rounded bg-gray-200 dark:bg-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<div className="h-5 w-16 rounded-full bg-gray-100 dark:bg-white/5" />
|
||||||
|
<div className="h-5 w-20 rounded-full bg-gray-100 dark:bg-white/5" />
|
||||||
|
<div className="h-5 w-14 rounded-full bg-gray-100 dark:bg-white/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileLoadingStack({
|
||||||
|
stackPeekOffsetPx,
|
||||||
|
stackExtraTopPx,
|
||||||
|
}: {
|
||||||
|
stackPeekOffsetPx: number
|
||||||
|
stackExtraTopPx: number
|
||||||
|
}) {
|
||||||
|
const backDepths = [2, 1]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-auto w-full max-w-[560px] overflow-y-visible" style={{ overflowX: 'clip' }}>
|
||||||
|
<div
|
||||||
|
className="relative overflow-visible"
|
||||||
|
style={{
|
||||||
|
paddingTop: stackExtraTopPx > 0 ? `${stackExtraTopPx}px` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backDepths.map((depth) => {
|
||||||
|
const y = -(depth * stackPeekOffsetPx)
|
||||||
|
const scale = 1 - depth * 0.03
|
||||||
|
const opacity = 1 - depth * 0.14
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`loading-back-${depth}`}
|
||||||
|
className="absolute inset-x-0 top-0 pointer-events-none will-change-[transform,opacity]"
|
||||||
|
style={{
|
||||||
|
zIndex: 20 - depth,
|
||||||
|
transform: `translateY(${y}px) scale(${scale}) translateZ(0)`,
|
||||||
|
opacity,
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<LoadingCardSkeleton blurred />
|
||||||
|
<div className="absolute inset-0 rounded-lg bg-white/8 dark:bg-black/8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="relative touch-pan-y" style={{ zIndex: 30 }}>
|
||||||
|
<LoadingCardSkeleton />
|
||||||
|
<div className="absolute inset-0 grid place-items-center pointer-events-none">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-gray-200/80 bg-white/88 px-3 py-2 shadow-sm backdrop-blur-sm dark:border-white/10 dark:bg-gray-900/82">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">Lade…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinishedDownloadsCardsView({
|
export default function FinishedDownloadsCardsView({
|
||||||
rows,
|
rows,
|
||||||
isSmall,
|
isSmall,
|
||||||
@ -866,12 +980,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
// Sichtbarer Stack bleibt bei 3 Karten
|
// Sichtbarer Stack bleibt bei 3 Karten
|
||||||
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
|
const mobileVisibleStackRows = isSmall ? rows.slice(0, mobileStackDepth) : []
|
||||||
|
|
||||||
// ✅ Mobile-Preload stark begrenzen (sonst zu viele hidden <FinishedVideoPreview/> Mounts)
|
|
||||||
const MOBILE_STILL_PRELOAD_LIMIT = 4 // 0..6 je nach Gerätetest
|
|
||||||
const mobileStillPreloadRows = isSmall
|
|
||||||
? rows.slice(mobileStackDepth, mobileStackDepth + MOBILE_STILL_PRELOAD_LIMIT)
|
|
||||||
: []
|
|
||||||
|
|
||||||
// größerer Peek-Offset für stärkeren Stack-Effekt
|
// größerer Peek-Offset für stärkeren Stack-Effekt
|
||||||
const stackPeekOffsetPx = 15
|
const stackPeekOffsetPx = 15
|
||||||
|
|
||||||
@ -879,12 +987,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
const stackExtraTopPx = (Math.min(mobileVisibleStackRows.length, mobileStackDepth) - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative">
|
||||||
className={[
|
|
||||||
'relative',
|
|
||||||
isLoading && rows.length === 0 ? 'min-h-[320px]' : '',
|
|
||||||
].filter(Boolean).join(' ')}
|
|
||||||
>
|
|
||||||
{!isSmall ? (
|
{!isSmall ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{rows.map((j) => {
|
{rows.map((j) => {
|
||||||
@ -899,7 +1002,14 @@ export default function FinishedDownloadsCardsView({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
|
<div className="relative overflow-y-visible touch-pan-y" style={{ overflowX: 'clip' }}>
|
||||||
{rows.length === 0 ? null : (
|
{rows.length === 0 ? (
|
||||||
|
isLoading ? (
|
||||||
|
<MobileLoadingStack
|
||||||
|
stackPeekOffsetPx={stackPeekOffsetPx}
|
||||||
|
stackExtraTopPx={stackExtraTopPx}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
|
className="relative mx-auto w-full max-w-[560px] overflow-y-visible"
|
||||||
style={{ overflowX: 'clip' }}
|
style={{ overflowX: 'clip' }}
|
||||||
@ -918,9 +1028,10 @@ export default function FinishedDownloadsCardsView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Hintere Karten zuerst (absolut, dekorativ) */}
|
||||||
{backRows
|
{backRows
|
||||||
.map((j, backIdx) => {
|
.map((j, backIdx) => {
|
||||||
const idx = backIdx + 1
|
const idx = backIdx + 1 // 1,2...
|
||||||
const { k, cardInner } = renderCardItem(j, {
|
const { k, cardInner } = renderCardItem(j, {
|
||||||
forceStill: true,
|
forceStill: true,
|
||||||
disableInline: true,
|
disableInline: true,
|
||||||
@ -957,6 +1068,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
})
|
})
|
||||||
.reverse()}
|
.reverse()}
|
||||||
|
|
||||||
|
{/* Oberste Karte im Flow -> bestimmt die echte Höhe */}
|
||||||
{topRow ? (() => {
|
{topRow ? (() => {
|
||||||
const j = topRow
|
const j = topRow
|
||||||
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
const { k, busy, isHot, cardInner, inlineDomId } = renderCardItem(j, {
|
||||||
@ -1020,44 +1132,12 @@ export default function FinishedDownloadsCardsView({
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mobileStillPreloadRows.length > 0 ? (
|
|
||||||
<div className="sr-only" aria-hidden="true">
|
|
||||||
{mobileStillPreloadRows.map((j) => {
|
|
||||||
const k = keyFor(j)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={`preload-still-${k}`} className="relative aspect-video">
|
|
||||||
<FinishedVideoPreview
|
|
||||||
job={j}
|
|
||||||
getFileName={baseName}
|
|
||||||
className="h-full w-full"
|
|
||||||
showPopover={false}
|
|
||||||
blur={Boolean(blurPreviews)}
|
|
||||||
animated={false}
|
|
||||||
teaserPreloadEnabled={true}
|
|
||||||
teaserPreloadRootMargin="1200px 0px"
|
|
||||||
animatedMode="teaser"
|
|
||||||
animatedTrigger="always"
|
|
||||||
inlineVideo={false}
|
|
||||||
inlineControls={false}
|
|
||||||
inlineLoop={false}
|
|
||||||
muted={true}
|
|
||||||
popoverMuted={true}
|
|
||||||
assetNonce={assetNonce ?? 0}
|
|
||||||
alwaysLoadStill
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && rows.length === 0 ? (
|
{!isSmall && isLoading && rows.length === 0 ? (
|
||||||
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
<div className="absolute inset-0 z-20 grid place-items-center rounded-lg bg-white/50 backdrop-blur-[2px] dark:bg-gray-950/40">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
<div className="flex items-center gap-3 rounded-lg border border-gray-200 bg-white/80 px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-transparent dark:border-white/20 dark:border-t-transparent" />
|
||||||
|
|||||||
@ -18,18 +18,23 @@ export default function LiveVideo({
|
|||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
const [broken, setBroken] = useState(false)
|
const [broken, setBroken] = useState(false)
|
||||||
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | null>(null)
|
const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null>(null)
|
||||||
|
|
||||||
const normalizeBrokenReason = (status?: string): 'private' | 'hidden' | 'away' | 'offline' => {
|
const normalizeBrokenReason = (
|
||||||
|
status?: string
|
||||||
|
): 'private' | 'hidden' | 'away' | 'offline' | 'unknown' => {
|
||||||
const s = String(status ?? '').trim().toLowerCase()
|
const s = String(status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
if (s === 'private') return 'private'
|
if (s === 'private') return 'private'
|
||||||
if (s === 'hidden') return 'hidden'
|
if (s === 'hidden') return 'hidden'
|
||||||
if (s === 'away') return 'away'
|
if (s === 'away') return 'away'
|
||||||
return 'offline'
|
if (s === 'offline') return 'offline'
|
||||||
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
const brokenMessage = (reason: 'private' | 'hidden' | 'away' | 'offline' | null): string => {
|
const brokenMessage = (
|
||||||
|
reason: 'private' | 'hidden' | 'away' | 'offline' | 'unknown' | null
|
||||||
|
): string => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'hidden':
|
case 'hidden':
|
||||||
return 'Cam is hidden'
|
return 'Cam is hidden'
|
||||||
@ -39,6 +44,8 @@ export default function LiveVideo({
|
|||||||
return 'Private show in progress.'
|
return 'Private show in progress.'
|
||||||
case 'offline':
|
case 'offline':
|
||||||
return 'Model is offline'
|
return 'Model is offline'
|
||||||
|
case 'unknown':
|
||||||
|
return 'Live video unavailable'
|
||||||
default:
|
default:
|
||||||
return 'Live video unavailable'
|
return 'Live video unavailable'
|
||||||
}
|
}
|
||||||
@ -94,16 +101,29 @@ export default function LiveVideo({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reason = normalizeBrokenReason(roomStatus)
|
||||||
|
|
||||||
|
if (reason === 'unknown') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setBroken(true)
|
setBroken(true)
|
||||||
setBrokenReason(normalizeBrokenReason(roomStatus))
|
setBrokenReason(reason)
|
||||||
}
|
}
|
||||||
}, 4_000)
|
}, 4_000)
|
||||||
|
|
||||||
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
|
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
|
||||||
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
|
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
|
||||||
const onError = () => {
|
const onError = () => {
|
||||||
|
const reason = normalizeBrokenReason(roomStatus)
|
||||||
|
|
||||||
|
if (reason === 'unknown') {
|
||||||
|
// bei unbekanntem Raumstatus nicht sofort hart fehlschlagen
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setBroken(true)
|
setBroken(true)
|
||||||
setBrokenReason(normalizeBrokenReason(roomStatus))
|
setBrokenReason(reason)
|
||||||
}
|
}
|
||||||
video.addEventListener('error', onError)
|
video.addEventListener('error', onError)
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,9 @@ export default function ModelPreview({
|
|||||||
const blurCls = blur ? 'blur-md' : ''
|
const blurCls = blur ? 'blur-md' : ''
|
||||||
const CONTROLBAR_H = 0
|
const CONTROLBAR_H = 0
|
||||||
|
|
||||||
|
const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase()
|
||||||
|
const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline'
|
||||||
|
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
|
// ✅ page visibility als REF (kein Rerender-Fanout bei visibilitychange)
|
||||||
@ -273,15 +276,17 @@ export default function ModelPreview({
|
|||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<LiveVideo
|
<LiveVideo
|
||||||
src={hq}
|
src={hq}
|
||||||
muted={true}
|
muted={false}
|
||||||
roomStatus={roomStatus}
|
roomStatus={roomStatus}
|
||||||
className="w-full h-full object-contain object-bottom relative z-0"
|
className="w-full h-full object-contain object-bottom relative z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showLiveBadge ? (
|
||||||
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
|
||||||
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />
|
||||||
Live
|
Live
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -308,6 +313,18 @@ 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) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{!apiImgError ? (
|
{!apiImgError ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@ -1272,16 +1272,16 @@ export default function ModelsTab() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="group h-full overflow-hidden rounded-md border border-gray-200 bg-slate-900/80 shadow-sm transition hover:shadow-md dark:border-white/10 flex flex-col"
|
className="group flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md dark:border-white/10 dark:bg-slate-900/80"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative cursor-pointer bg-slate-950"
|
className="relative cursor-pointer bg-gray-100 dark:bg-slate-950"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (href) window.open(href, '_blank', 'noreferrer')
|
if (href) window.open(href, '_blank', 'noreferrer')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Bildbereich ähnlich Screenshot */}
|
{/* Bildbereich ähnlich Screenshot */}
|
||||||
<div className="relative aspect-[3/4] w-full overflow-hidden bg-[#12202c]">
|
<div className="relative aspect-[3/4] w-full overflow-hidden bg-gradient-to-br from-gray-100 to-slate-200 dark:from-[#12202c] dark:to-[#0b1620]">
|
||||||
{imgSrc ? (
|
{imgSrc ? (
|
||||||
<img
|
<img
|
||||||
src={imgSrc}
|
src={imgSrc}
|
||||||
@ -1299,7 +1299,7 @@ export default function ModelsTab() {
|
|||||||
{/* Fallback-Name im Bild */}
|
{/* Fallback-Name im Bild */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'absolute inset-0 items-center justify-center px-3 text-center font-semibold text-slate-500 leading-tight',
|
'absolute inset-0 items-center justify-center px-3 text-center font-semibold leading-tight text-slate-600 dark:text-slate-500',
|
||||||
imgSrc ? 'hidden' : 'flex'
|
imgSrc ? 'hidden' : 'flex'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@ -1320,7 +1320,7 @@ export default function ModelsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
|
{/* dunkler Verlauf unten für bessere Lesbarkeit */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/15 to-transparent dark:from-black/70 dark:via-black/20" />
|
||||||
|
|
||||||
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
|
{/* Modelname im Bild unten links (mit Safe-Area rechts für Stats) */}
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-2.5 pr-18 sm:pr-2.5">
|
||||||
@ -1378,7 +1378,7 @@ export default function ModelsTab() {
|
|||||||
flex items-center gap-1
|
flex items-center gap-1
|
||||||
[&_button]:h-7 [&_button]:w-7 [&_button]:min-w-0 [&_button]:p-0
|
[&_button]:h-7 [&_button]:w-7 [&_button]:min-w-0 [&_button]:p-0
|
||||||
[&_button]:bg-transparent [&_button]:shadow-none
|
[&_button]:bg-transparent [&_button]:shadow-none
|
||||||
[&_button:hover]:bg-white/10
|
[&_button:hover]:bg-white/20 dark:[&_button:hover]:bg-white/10
|
||||||
[&_button]:border-0
|
[&_button]:border-0
|
||||||
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
|
[&_button_svg]:drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]
|
||||||
"
|
"
|
||||||
@ -1437,7 +1437,7 @@ export default function ModelsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="border-t border-white/5 bg-slate-800/70 px-2.5 py-2 rounded-b-md flex-1">
|
<div className="flex-1 rounded-b-xl border-t border-gray-200 bg-gray-50 px-2.5 py-2 dark:border-white/5 dark:bg-slate-800/70">
|
||||||
{/* Mobile: kompakter, aber nicht gequetscht */}
|
{/* Mobile: kompakter, aber nicht gequetscht */}
|
||||||
<div className="sm:hidden">
|
<div className="sm:hidden">
|
||||||
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
|
{/* Zeile 1: Actions als Touch-freundliche 3er-Reihe */}
|
||||||
@ -1451,8 +1451,8 @@ export default function ModelsTab() {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'h-8 min-w-0 px-0 shadow-none',
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
watch
|
watch
|
||||||
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
|
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1461,7 +1461,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
👁
|
👁
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1473,8 +1473,8 @@ export default function ModelsTab() {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'h-8 min-w-0 px-0 shadow-none',
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
fav
|
fav
|
||||||
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
|
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1484,7 +1484,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
★
|
★
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1496,8 +1496,8 @@ export default function ModelsTab() {
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
'h-8 min-w-0 px-0 shadow-none',
|
'h-8 min-w-0 px-0 shadow-none',
|
||||||
liked
|
liked
|
||||||
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
|
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1507,7 +1507,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
♥
|
♥
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1546,8 +1546,8 @@ export default function ModelsTab() {
|
|||||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
: 'opacity-100',
|
: 'opacity-100',
|
||||||
watch
|
watch
|
||||||
? 'bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30'
|
? 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:hover:bg-indigo-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1556,7 +1556,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', watch ? 'text-indigo-600 dark:text-indigo-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
👁
|
👁
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1571,8 +1571,8 @@ export default function ModelsTab() {
|
|||||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
: 'opacity-100',
|
: 'opacity-100',
|
||||||
fav
|
fav
|
||||||
? 'bg-amber-500/20 text-amber-200 hover:bg-amber-500/30'
|
? 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-200 dark:hover:bg-amber-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1582,7 +1582,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', fav ? 'text-amber-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', fav ? 'text-amber-600 dark:text-amber-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
★
|
★
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@ -1597,8 +1597,8 @@ export default function ModelsTab() {
|
|||||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
|
||||||
: 'opacity-100',
|
: 'opacity-100',
|
||||||
liked
|
liked
|
||||||
? 'bg-rose-500/20 text-rose-200 hover:bg-rose-500/30'
|
? 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-500/20 dark:text-rose-200 dark:hover:bg-rose-500/30'
|
||||||
: 'bg-white/5 text-slate-200 hover:bg-white/10'
|
: 'bg-white text-gray-700 ring-1 ring-gray-200 hover:bg-gray-100 dark:bg-white/5 dark:text-slate-200 dark:ring-0 dark:hover:bg-white/10'
|
||||||
)}
|
)}
|
||||||
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -1608,7 +1608,7 @@ export default function ModelsTab() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center justify-center gap-1">
|
<span className="inline-flex items-center justify-center gap-1">
|
||||||
<span className={clsx('text-xl leading-none', liked ? 'text-rose-300' : 'text-slate-300')}>
|
<span className={clsx('text-xl leading-none', liked ? 'text-rose-600 dark:text-rose-300' : 'text-gray-500 dark:text-slate-300')}>
|
||||||
♥
|
♥
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -449,7 +449,7 @@ export default function Player({
|
|||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const hlsIndexUrl = React.useMemo(() => {
|
const hlsIndexUrl = React.useMemo(() => {
|
||||||
const u = `/api/preview?id=${encodeURIComponent(previewId)}&file=index_hq.m3u8&play=1`
|
const u = `/api/preview?id=${encodeURIComponent(previewId)}&play=1`
|
||||||
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
|
return apiUrl(isRunning ? `${u}&t=${Date.now()}` : u)
|
||||||
}, [previewId, isRunning])
|
}, [previewId, isRunning])
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user