sse update

This commit is contained in:
Linrador 2026-03-09 14:21:36 +01:00
parent 6f12d3c2b1
commit ceb310a428
27 changed files with 2207 additions and 984 deletions

View File

@ -271,6 +271,74 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
return m return m
} }
func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) {
if store == nil {
return
}
seenOnline := make(map[string]bool, len(rooms))
for _, rm := range rooms {
modelKey := strings.ToLower(strings.TrimSpace(rm.Username))
if modelKey == "" {
continue
}
seenOnline[modelKey] = true
img := strings.TrimSpace(rm.ImageURL360)
if img == "" {
img = strings.TrimSpace(rm.ImageURL)
}
_ = store.SetChaturbateRoomState(
"chaturbate.com",
modelKey,
rm.CurrentShow,
true,
rm.ChatRoomURL,
img,
fetchedAt,
)
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok {
publishRoomStateForModel(sm)
}
}
// bekannte Chaturbate-Models, die NICHT im Online-Snapshot sind => offline setzen
models := store.List()
for _, m := range models {
if strings.ToLower(strings.TrimSpace(m.Host)) != "chaturbate.com" {
continue
}
modelKey := strings.ToLower(strings.TrimSpace(m.ModelKey))
if modelKey == "" {
continue
}
if seenOnline[modelKey] {
continue
}
_ = store.SetChaturbateRoomState(
"chaturbate.com",
modelKey,
"offline",
false,
"",
"",
fetchedAt,
)
if sm, ok := store.GetByHostAndModelKey("chaturbate.com", modelKey); ok {
publishRoomStateForModel(sm)
}
}
}
// --- Profilbild Download + Persist (online -> offline) --- // --- Profilbild Download + Persist (online -> offline) ---
func selectBestRoomImageURL(rm ChaturbateRoom) string { func selectBestRoomImageURL(rm ChaturbateRoom) string {
@ -456,11 +524,15 @@ func startChaturbateOnlinePoller(store *ModelStore) {
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren // ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
if cbModelStore != nil { if cbModelStore != nil {
go func(roomsCopy []ChaturbateRoom, fetchedAt time.Time) { go syncChaturbateRoomStateIntoModelStore(
if err := cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt); err != nil && verboseLogs() { cbModelStore,
fmt.Println("⚠️ [chaturbate] sync known models failed:", err) append([]ChaturbateRoom(nil), rooms...),
} fetchedAtNow,
}(append([]ChaturbateRoom(nil), rooms...), fetchedAtNow) )
if len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
}
} }
// Tags übernehmen ist teuer -> nur selten + im Hintergrund // Tags übernehmen ist teuer -> nur selten + im Hintergrund
@ -611,7 +683,8 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
return return
} }
changedAny := false toStop := make([]*RecordJob, 0, 4)
changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
@ -628,21 +701,25 @@ func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua st
j.PreviewCookie = cookie j.PreviewCookie = cookie
j.PreviewUA = ua j.PreviewUA = ua
// Wenn ffmpeg schon läuft und sich Quelle geändert hat -> hart stoppen
if old != "" && old != newHls { if old != "" && old != newHls {
stopPreview(j)
// PreviewState zurücksetzen (damit "private/offline" nicht hängen bleibt)
j.PreviewState = "" j.PreviewState = ""
j.PreviewStateAt = "" j.PreviewStateAt = ""
j.PreviewStateMsg = "" j.PreviewStateMsg = ""
toStop = append(toStop, j)
} }
changedAny = true if !j.Hidden {
changedJobs = append(changedJobs, j)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
if changedAny { for _, j := range toStop {
notifyJobsChanged() stopPreview(j)
}
for _, j := range changedJobs {
publishJobUpsert(j)
} }
} }

View File

@ -86,6 +86,7 @@ func stopJobsInternal(list []*RecordJob) {
} }
pl := make([]payload, 0, len(list)) pl := make([]payload, 0, len(list))
visibleJobs := make([]*RecordJob, 0, len(list))
jobsMu.Lock() jobsMu.Lock()
for _, job := range list { for _, job := range list {
@ -96,10 +97,16 @@ func stopJobsInternal(list []*RecordJob) {
job.Progress = 10 job.Progress = 10
pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel}) pl = append(pl, payload{cmd: job.previewCmd, cancel: job.cancel})
job.previewCmd = nil job.previewCmd = nil
if !job.Hidden {
visibleJobs = append(visibleJobs, job)
}
} }
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() // 1) UI sofort updaten (Phase/Progress) for _, job := range visibleJobs {
publishJobUpsert(job)
}
for _, p := range pl { for _, p := range pl {
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
@ -110,7 +117,9 @@ func stopJobsInternal(list []*RecordJob) {
} }
} }
notifyJobsChanged() // 2) optional: nach Cancel/Kill nochmal pushen for _, job := range visibleJobs {
publishJobUpsert(job)
}
} }
func stopAllStoppableJobs() int { func stopAllStoppableJobs() int {

View File

@ -358,8 +358,12 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewState = "" job.PreviewState = ""
job.PreviewStateAt = "" job.PreviewStateAt = ""
job.PreviewStateMsg = "" job.PreviewStateMsg = ""
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if !hidden {
publishJobUpsert(job)
}
commonIn := []string{"-y"} commonIn := []string{"-y"}
if strings.TrimSpace(userAgent) != "" { if strings.TrimSpace(userAgent) != "" {
@ -427,8 +431,12 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h
job.PreviewStateMsg = st job.PreviewStateMsg = st
} }
} }
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged()
if !hidden {
publishJobUpsert(job)
}
fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st) fmt.Printf("⚠️ preview hq ffmpeg failed: %v (%s)\n", err, st)
} }

View File

@ -271,7 +271,7 @@ func publishJob(jobID string) bool {
j.Hidden = false j.Hidden = false
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(j)
return true return true
} }
@ -784,6 +784,9 @@ func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
} }
func setJobPhase(job *RecordJob, phase string, progress int) { func setJobPhase(job *RecordJob, phase string, progress int) {
if job == nil {
return
}
if progress < 0 { if progress < 0 {
progress = 0 progress = 0
} }
@ -794,10 +797,12 @@ func setJobPhase(job *RecordJob, phase string, progress int) {
jobsMu.Lock() jobsMu.Lock()
job.Phase = phase job.Phase = phase
job.Progress = progress job.Progress = progress
hidden := job.Hidden
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() if !hidden {
publishJobUpsert(job)
}
} }
func durationSecondsCached(ctx context.Context, path string) (float64, error) { func durationSecondsCached(ctx context.Context, path string) (float64, error) {
@ -1659,7 +1664,8 @@ func removeJobsByOutputBasename(file string) {
return return
} }
removed := false removedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for id, j := range jobs { for id, j := range jobs {
if j == nil { if j == nil {
@ -1670,14 +1676,16 @@ func removeJobsByOutputBasename(file string) {
continue continue
} }
if filepath.Base(out) == file { if filepath.Base(out) == file {
if !j.Hidden {
removedJobs = append(removedJobs, j)
}
delete(jobs, id) delete(jobs, id)
removed = true
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if removed { for _, j := range removedJobs {
notifyJobsChanged() publishJobRemove(j)
} }
} }
@ -1688,7 +1696,8 @@ func renameJobsOutputBasename(oldFile, newFile string) {
return return
} }
changed := false changedJobs := make([]*RecordJob, 0, 4)
jobsMu.Lock() jobsMu.Lock()
for _, j := range jobs { for _, j := range jobs {
if j == nil { if j == nil {
@ -1700,13 +1709,15 @@ func renameJobsOutputBasename(oldFile, newFile string) {
} }
if filepath.Base(out) == oldFile { if filepath.Base(out) == oldFile {
j.Output = filepath.Join(filepath.Dir(out), newFile) j.Output = filepath.Join(filepath.Dir(out), newFile)
changed = true if !j.Hidden {
changedJobs = append(changedJobs, j)
}
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if changed { for _, j := range changedJobs {
notifyJobsChanged() publishJobUpsert(j)
} }
} }

View File

@ -11,9 +11,28 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
var (
modelStoreMu sync.RWMutex
modelStoreRef *ModelStore
)
func getModelStore() *ModelStore {
modelStoreMu.RLock()
defer modelStoreMu.RUnlock()
return modelStoreRef
}
func setModelStore(store *ModelStore) {
modelStoreMu.Lock()
defer modelStoreMu.Unlock()
modelStoreRef = store
setCoverModelStore(store)
}
// ✅ umbenannt, damit es nicht mit models.go kollidiert // ✅ umbenannt, damit es nicht mit models.go kollidiert
func modelsWriteJSON(w http.ResponseWriter, status int, v any) { func modelsWriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
@ -220,6 +239,7 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
} }
func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
setModelStore(store)
// ✅ NEU: Parse-Endpoint (nur URL erlaubt) // ✅ NEU: Parse-Endpoint (nur URL erlaubt)
mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) {
@ -245,7 +265,15 @@ 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
} }
modelsWriteJSON(w, http.StatusOK, store.Meta())
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
meta := store.Meta()
modelsWriteJSON(w, http.StatusOK, meta)
}) })
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
@ -254,6 +282,13 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
host := strings.TrimSpace(r.URL.Query().Get("host")) host := strings.TrimSpace(r.URL.Query().Get("host"))
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host)) modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
}) })
@ -265,8 +300,13 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
// ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen: // ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen:
// Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten. // Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten.
list := store.List() store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
list := store.List()
modelsWriteJSON(w, http.StatusOK, list) modelsWriteJSON(w, http.StatusOK, list)
}) })
@ -283,6 +323,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
mime, data, ok, err := store.GetProfileImageByID(id) mime, data, ok, err := store.GetProfileImageByID(id)
if err != nil { if err != nil {
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
@ -310,6 +356,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
host := strings.TrimSpace(r.FormValue("host")) host := strings.TrimSpace(r.FormValue("host"))
modelKey := strings.TrimSpace(r.FormValue("modelKey")) modelKey := strings.TrimSpace(r.FormValue("modelKey"))
sourceURL := strings.TrimSpace(r.FormValue("sourceUrl")) sourceURL := strings.TrimSpace(r.FormValue("sourceUrl"))
@ -368,6 +420,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
m, err := store.UpsertFromParsed(req) m, err := store.UpsertFromParsed(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()})
@ -402,6 +460,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
return return
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
m, err := store.EnsureByHostModelKey(host, key) m, err := store.EnsureByHostModelKey(host, key)
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()})
@ -435,6 +499,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
} }
defer f.Close() defer f.Close()
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
res, err := importModelsCSV(store, f, kind) res, err := importModelsCSV(store, f, kind)
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()})
@ -471,6 +541,13 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
} }
req.ID = ensured.ID req.ID = ensured.ID
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
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()})
@ -496,6 +573,13 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
var req struct { var req struct {
ID string `json:"id"` ID string `json:"id"`
} }
store := getModelStore()
if store == nil {
modelsWriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "model store nicht verfügbar"})
return
}
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

View File

@ -1,4 +1,5 @@
// backend/models_store.go // backend/models_store.go
package main package main
import ( import (
@ -36,6 +37,14 @@ type StoredModel struct {
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=... ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
LastOnlineAt string `json:"lastOnlineAt,omitempty"`
LastOfflineAt string `json:"lastOfflineAt,omitempty"`
LastRoomSyncAt string `json:"lastRoomSyncAt,omitempty"`
Watching bool `json:"watching"` Watching bool `json:"watching"`
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
Hot bool `json:"hot"` Hot bool `json:"hot"`
@ -184,6 +193,105 @@ func (s *ModelStore) init() error {
return nil return nil
} }
func (s *ModelStore) SetChaturbateRoomState(
host string,
modelKey string,
roomStatus string,
isOnline bool,
chatRoomURL string,
imageURL string,
seenAt time.Time,
) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
modelKey = strings.TrimSpace(modelKey)
roomStatus = strings.ToLower(strings.TrimSpace(roomStatus))
chatRoomURL = strings.TrimSpace(chatRoomURL)
imageURL = strings.TrimSpace(imageURL)
if host == "" {
host = "chaturbate.com"
}
if modelKey == "" {
return errors.New("modelKey fehlt")
}
seenAt = seenAt.UTC()
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`
UPDATE models
SET
room_status = $1,
is_online = $2,
chat_room_url = $3,
image_url = CASE
WHEN COALESCE(trim($4), '') <> '' THEN $4
ELSE image_url
END,
last_room_sync_at = $5,
last_online_at = CASE
WHEN $2 = true THEN $5
ELSE last_online_at
END,
last_offline_at = CASE
WHEN $2 = false THEN $5
ELSE last_offline_at
END,
updated_at = $6
WHERE lower(trim(host)) = lower(trim($7))
AND lower(trim(model_key)) = lower(trim($8));
`,
roomStatus,
isOnline,
nullableStringArg(chatRoomURL),
nullableStringArg(imageURL),
seenAt,
now,
host,
modelKey,
)
return err
}
func (s *ModelStore) GetByHostAndModelKey(host string, modelKey string) (*StoredModel, bool) {
if err := s.ensureInit(); err != nil {
return nil, false
}
host = canonicalHost(host)
modelKey = strings.TrimSpace(modelKey)
if host == "" || modelKey == "" {
return nil, false
}
var id string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, modelKey).Scan(&id)
if err != nil {
return nil, false
}
m, err := s.getByID(id)
if err != nil {
return nil, false
}
return &m, true
}
func canonicalHost(host string) string { func canonicalHost(host string) string {
h := strings.ToLower(strings.TrimSpace(host)) h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimPrefix(h, "www.") h = strings.TrimPrefix(h, "www.")
@ -841,10 +949,17 @@ SELECT
last_seen_online_at, last_seen_online_at,
COALESCE(cb_online_json,''), COALESCE(cb_online_json,''),
cb_online_fetched_at, cb_online_fetched_at,
COALESCE(cb_online_last_error,''), -- optional COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at, updated_at created_at, updated_at
FROM models FROM models
@ -865,10 +980,17 @@ SELECT
last_seen_online_at, last_seen_online_at,
COALESCE(cb_online_json,''), COALESCE(cb_online_json,''),
cb_online_fetched_at, cb_online_fetched_at,
''::text as cb_online_last_error, -- fallback dummy ''::text as cb_online_last_error,
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at, updated_at created_at, updated_at
FROM models FROM models
@ -907,6 +1029,14 @@ ORDER BY updated_at DESC;
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
roomStatus string
isOnline bool
chatRoomURL string
imageURL string
lastOnlineAt sql.NullTime
lastOfflineAt sql.NullTime
lastRoomSyncAt sql.NullTime
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
@ -919,6 +1049,8 @@ ORDER BY updated_at DESC;
&lastSeenOnline, &lastSeenOnlineAt, &lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError, &cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&roomStatus, &isOnline, &chatRoomURL, &imageURL,
&lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
); err != nil { ); err != nil {
@ -941,6 +1073,17 @@ ORDER BY updated_at DESC;
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt), CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError, CbOnlineLastError: cbOnlineLastError,
ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt),
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt),
LastOfflineAt: fmtNullTime(lastOfflineAt),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt),
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, Hot: hot,
@ -949,9 +1092,6 @@ ORDER BY updated_at DESC;
CreatedAt: fmtTime(createdAt), CreatedAt: fmtTime(createdAt),
UpdatedAt: fmtTime(updatedAt), UpdatedAt: fmtTime(updatedAt),
ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt),
} }
if hasProfileImage != 0 { if hasProfileImage != 0 {
@ -1597,6 +1737,14 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
roomStatus string
isOnline bool
chatRoomURL string
imageURL string
lastOnlineAt sql.NullTime
lastOfflineAt sql.NullTime
lastRoomSyncAt sql.NullTime
watching, favorite, hot, keep bool watching, favorite, hot, keep bool
liked sql.NullBool liked sql.NullBool
@ -1621,6 +1769,13 @@ SELECT
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at, updated_at created_at, updated_at
FROM models FROM models
@ -1646,6 +1801,13 @@ SELECT
COALESCE(profile_image_url,''), COALESCE(profile_image_url,''),
profile_image_updated_at, profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked, watching,favorite,hot,keep,liked,
created_at, updated_at created_at, updated_at
FROM models FROM models
@ -1659,6 +1821,8 @@ WHERE id=$1;
&lastSeenOnline, &lastSeenOnlineAt, &lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError, &cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&roomStatus, &isOnline, &chatRoomURL, &imageURL,
&lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
) )
@ -1699,6 +1863,14 @@ WHERE id=$1;
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt), CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError, CbOnlineLastError: cbOnlineLastError,
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt),
LastOfflineAt: fmtNullTime(lastOfflineAt),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt),
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, Hot: hot,

View File

@ -179,9 +179,8 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) {
delete(jobs, jobID) delete(jobs, jobID)
jobsMu.Unlock() jobsMu.Unlock()
// ✅ wenn der Job nie sichtbar war, nicht unnötig UI refreshen
if wasVisible { if wasVisible {
notifyJobsChanged() publishJobRemove(job)
} }
} }

View File

@ -211,7 +211,7 @@ func startPostWorkStatusRefresher() {
defer t.Stop() defer t.Stop()
for range t.C { for range t.C {
changed := false changedJobs := make([]*RecordJob, 0, 16)
jobsMu.Lock() jobsMu.Lock()
for _, job := range jobs { for _, job := range jobs {
@ -222,17 +222,16 @@ func startPostWorkStatusRefresher() {
st := postWorkQ.StatusForKey(key) st := postWorkQ.StatusForKey(key)
// ✅ Kein Typname nötig: job.PostWork ist *<StatusType>, st ist <StatusType>
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) { if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) {
tmp := st tmp := st
job.PostWork = &tmp job.PostWork = &tmp
changed = true changedJobs = append(changedJobs, job)
} }
} }
jobsMu.Unlock() jobsMu.Unlock()
if changed { for _, job := range changedJobs {
notifyJobsChanged() publishJobUpsert(job)
} }
} }
}() }()

View File

@ -1594,7 +1594,6 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
removeJobsByOutputBasename(file) removeJobsByOutputBasename(file)
notifyDoneChanged() notifyDoneChanged()
notifyJobsChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,
@ -1976,7 +1975,6 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) {
renameJobsOutputBasename(file, newFile) renameJobsOutputBasename(file, newFile)
notifyDoneChanged() notifyDoneChanged()
notifyJobsChanged()
respondJSON(w, map[string]any{ respondJSON(w, map[string]any{
"ok": true, "ok": true,

View File

@ -114,7 +114,7 @@ func RecordStream(
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = written job.SizeBytes = written
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
lastPush = now lastPush = now
lastBytes = written lastBytes = written

View File

@ -396,7 +396,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = sz job.SizeBytes = sz
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
last = sz last = sz
} }
} }

View File

@ -31,18 +31,19 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
} }
type rng struct{ start, end int } type rng struct{ start, end int }
rangeFor := func(ph string) rng { rangeFor := func(ph string) rng {
switch ph { switch ph {
case "postwork": case "postwork":
return rng{0, 5} return rng{0, 8}
case "remuxing": case "remuxing":
return rng{5, 65} return rng{8, 42}
case "moving": case "moving":
return rng{65, 75} return rng{42, 58}
case "probe": case "probe":
return rng{75, 80} return rng{58, 72}
case "assets": case "assets":
return rng{80, 99} return rng{72, 99}
default: default:
return rng{0, 100} return rng{0, 100}
} }
@ -62,34 +63,35 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
job.Phase = phase job.Phase = phase
} }
if phaseLower == "postwork" && pct == 0 { // recording = direkter Prozentwert
job.Progress = 0 if !inPostwork {
if pct < job.Progress {
pct = job.Progress
}
job.Progress = pct
return return
} }
mapped := pct // postwork-Phasen: pct ist IMMER lokal 0..100 innerhalb der Phase
r := rangeFor(phaseLower)
width := float64(r.end - r.start)
if inPostwork { mapped := r.start
r := rangeFor(phaseLower) if width > 0 {
if r.end >= r.start { mapped = r.start + int(math.Round((float64(pct)/100.0)*width))
if pct >= r.start && pct <= r.end { }
mapped = pct
} else { if mapped < r.start {
width := float64(r.end - r.start) mapped = r.start
mapped = r.start + int(math.Round((float64(pct)/100.0)*width)) }
} if mapped > r.end {
if mapped < r.start { mapped = r.end
mapped = r.start
}
if mapped > r.end {
mapped = r.end
}
}
} }
if mapped < job.Progress { if mapped < job.Progress {
mapped = job.Progress mapped = job.Progress
} }
job.Progress = mapped job.Progress = mapped
} }
@ -263,7 +265,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
j.Hidden = false j.Hidden = false
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(j)
return j, nil return j, nil
} }
@ -314,7 +316,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
jobsMu.Unlock() jobsMu.Unlock()
if !job.Hidden { if !job.Hidden {
notifyJobsChanged() publishJobUpsert(job)
} }
go runJob(ctx, job, req) go runJob(ctx, job, req)
@ -346,7 +348,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
} }
setJobProgress(job, "recording", 0) setJobProgress(job, "recording", 0)
notifyJobsChanged() publishJobUpsert(job)
switch provider { switch provider {
case "chaturbate": case "chaturbate":
@ -379,7 +381,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} }
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
@ -400,7 +402,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = outPath job.Output = outPath
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
err = RecordStreamMFC(ctx, hc, username, outPath, job) err = RecordStreamMFC(ctx, hc, username, outPath, job)
@ -436,7 +438,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.Phase = "postwork" job.Phase = "postwork"
out := strings.TrimSpace(job.Output) out := strings.TrimSpace(job.Output)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
if out == "" { if out == "" {
jobsMu.Lock() jobsMu.Lock()
@ -446,7 +448,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
return return
} }
@ -462,7 +464,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobRemove(job)
notifyDoneChanged() notifyDoneChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
@ -485,7 +487,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.SizeBytes = fi.Size() job.SizeBytes = fi.Size()
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
s := getSettings() s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB minMB := s.AutoDeleteSmallDownloadsBelowMB
@ -508,7 +510,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobRemove(job)
notifyDoneChanged() notifyDoneChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
@ -536,7 +538,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWork = &s job.PostWork = &s
} }
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
okQueued := postWorkQ.Enqueue(PostWorkTask{ okQueued := postWorkQ.Enqueue(PostWorkTask{
Key: postKey, Key: postKey,
@ -549,7 +551,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Unlock() jobsMu.Unlock()
setJobProgress(job, "postwork", 0) setJobProgress(job, "postwork", 0)
notifyJobsChanged() publishJobUpsert(job)
} }
out := strings.TrimSpace(postOut) out := strings.TrimSpace(postOut)
@ -561,7 +563,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
return nil return nil
} }
@ -572,23 +574,23 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} }
// 1) Remux // 1) Remux
if strings.EqualFold(filepath.Ext(out), ".ts") { if strings.EqualFold(filepath.Ext(out), ".ts") {
setPhase("remuxing", 72) setPhase("remuxing", 10)
if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" { if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" {
out = strings.TrimSpace(newOut) out = strings.TrimSpace(newOut)
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} }
} }
// 2) Move to done // 2) Move to done
setPhase("moving", 78) setPhase("moving", 10)
// ✅ auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben // ✅ auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben
{ {
@ -601,7 +603,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
delete(jobs, job.ID) delete(jobs, job.ID)
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobRemove(job)
notifyDoneChanged() notifyDoneChanged()
if shouldLogRecordInfo(req) { if shouldLogRecordInfo(req) {
@ -622,25 +624,25 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.Output = out job.Output = out
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
} }
// 3) Duration // 3) Duration
setPhase("probe", 84) setPhase("probe", 35)
{ {
dctx, cancel := context.WithTimeout(ctx, 6*time.Second) dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 { if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
jobsMu.Lock() jobsMu.Lock()
job.DurationSeconds = sec job.DurationSeconds = sec
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} }
cancel() cancel()
} }
// 4) Video props // 4) Video props
setPhase("probe", 86) setPhase("probe", 75)
{ {
pctx, cancel := context.WithTimeout(ctx, 6*time.Second) pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
w, h, fps, perr := probeVideoProps(pctx, out) w, h, fps, perr := probeVideoProps(pctx, out)
@ -651,17 +653,12 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.VideoHeight = h job.VideoHeight = h
job.FPS = fps job.FPS = fps
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} }
} }
// 5) Assets with progress // 5) Assets with progress
const ( setPhase("assets", 0)
assetsStart = 86
assetsEnd = 99
)
setPhase("assets", assetsStart)
lastPct := -1 lastPct := -1
lastTick := time.Time{} lastTick := time.Time{}
@ -673,19 +670,22 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
if r > 1 { if r > 1 {
r = 1 r = 1
} }
pct := assetsStart + int(math.Round(r*float64(assetsEnd-assetsStart)))
if pct < assetsStart { pct := int(math.Round(r * 100))
pct = assetsStart if pct < 0 {
pct = 0
} }
if pct > assetsEnd { if pct > 100 {
pct = assetsEnd pct = 100
} }
if pct == lastPct { if pct == lastPct {
return return
} }
if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond { if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond {
return return
} }
lastPct = pct lastPct = pct
lastTick = time.Now() lastTick = time.Now()
setPhase("assets", pct) setPhase("assets", pct)
@ -694,7 +694,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil { if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
fmt.Println("⚠️ ensureAssetsForVideo:", err) fmt.Println("⚠️ ensureAssetsForVideo:", err)
} }
setPhase("assets", assetsEnd) setPhase("assets", 100)
// Finalize // Finalize
jobsMu.Lock() jobsMu.Lock()
@ -704,7 +704,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
return nil return nil
}, },
@ -715,7 +715,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
jobsMu.Lock() jobsMu.Lock()
job.PostWork = &st job.PostWork = &st
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
} else { } else {
jobsMu.Lock() jobsMu.Lock()
job.Status = postTarget job.Status = postTarget
@ -724,7 +724,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.PostWorkKey = "" job.PostWorkKey = ""
job.PostWork = nil job.PostWork = nil
jobsMu.Unlock() jobsMu.Unlock()
notifyJobsChanged() publishJobUpsert(job)
notifyDoneChanged() notifyDoneChanged()
} }
} }

View File

@ -31,8 +31,6 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/cookies", cookiesHandler) api.HandleFunc("/api/cookies", cookiesHandler)
api.HandleFunc("/api/events/stream", eventsStream) api.HandleFunc("/api/events/stream", eventsStream)
api.HandleFunc("/api/record/done/stream", doneStream)
api.HandleFunc("/api/perf/stream", perfStreamHandler) api.HandleFunc("/api/perf/stream", perfStreamHandler)
api.HandleFunc("/api/status/disk", diskStatusHandler) api.HandleFunc("/api/status/disk", diskStatusHandler)
@ -53,7 +51,6 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame) api.HandleFunc("/api/preview-scrubber/", recordPreviewScrubberFrame)
api.HandleFunc("/api/preview-sprite/", recordPreviewSprite) api.HandleFunc("/api/preview-sprite/", recordPreviewSprite)
api.HandleFunc("/api/record/list", recordList) api.HandleFunc("/api/record/list", recordList)
api.HandleFunc("/api/record/stream", recordStream)
api.HandleFunc("/api/record/done/meta", recordDoneMeta) api.HandleFunc("/api/record/done/meta", recordDoneMeta)
api.HandleFunc("/api/record/video", recordVideo) api.HandleFunc("/api/record/video", recordVideo)
api.HandleFunc("/api/record/done", recordDoneList) api.HandleFunc("/api/record/done", recordDoneList)
@ -73,8 +70,6 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
// Tasks // Tasks
api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
// -------------------------- // --------------------------
// 3) ModelStore (Postgres) // 3) ModelStore (Postgres)
// DSN kommt aus Settings: databaseUrl + gespeichertes Passwort // DSN kommt aus Settings: databaseUrl + gespeichertes Passwort

View File

@ -332,12 +332,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
} }
// 3) Wenn Frontend ein Passwort sendet, hat das Priorität. // 3) Wenn Frontend ein Passwort sendet, hat das Priorität.
current := getSettings()
plainPW := strings.TrimSpace(in.DBPassword) plainPW := strings.TrimSpace(in.DBPassword)
if plainPW == "" { if plainPW == "" {
plainPW = pwFromURL plainPW = pwFromURL
} }
// 4) Wenn wir ein neues Passwort haben: encrypten & speichern (nur encrypted!)
if plainPW != "" { if plainPW != "" {
enc, err := encryptSettingString(plainPW) enc, err := encryptSettingString(plainPW)
if err != nil { if err != nil {
@ -345,8 +346,14 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
in.EncryptedDBPassword = enc in.EncryptedDBPassword = enc
} else {
in.EncryptedDBPassword = current.EncryptedDBPassword
} }
dbChanged :=
strings.TrimSpace(in.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) ||
strings.TrimSpace(in.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword)
// ✅ Settings im RAM aktualisieren // ✅ Settings im RAM aktualisieren
settingsMu.Lock() settingsMu.Lock()
settings = in.RecorderSettings settings = in.RecorderSettings
@ -355,6 +362,24 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
// ✅ Settings auf Disk persistieren // ✅ Settings auf Disk persistieren
saveSettingsToDisk() saveSettingsToDisk()
// ✅ Wenn DB geändert wurde: ModelStore sofort auf neue DB umstellen
if dbChanged {
dsn, err := buildPostgresDSNFromSettings()
if err != nil {
http.Error(w, "ungültige Datenbank-Konfiguration: "+err.Error(), http.StatusBadRequest)
return
}
newStore := NewModelStore(dsn)
if err := newStore.Load(); err != nil {
http.Error(w, "Datenbank-Verbindung fehlgeschlagen: "+err.Error(), http.StatusBadRequest)
return
}
setModelStore(newStore)
setChaturbateOnlineModelStore(newStore)
}
// ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen // ✅ ffmpeg/ffprobe nach Änderungen neu bestimmen
// Tipp: wenn der User FFmpegPath explizit setzt, nutze den direkt. // Tipp: wenn der User FFmpegPath explizit setzt, nutze den direkt.
if strings.TrimSpace(in.FFmpegPath) != "" { if strings.TrimSpace(in.FFmpegPath) != "" {

View File

@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"sort" "sort"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@ -20,11 +21,286 @@ type appSSE struct {
var sseApp *appSSE var sseApp *appSSE
type jobEvent struct {
Type string `json:"type"`
Model string `json:"model"`
JobID string `json:"jobId"`
Status JobStatus `json:"status"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
Output string `json:"output,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`
EndedAt string `json:"endedAt,omitempty"`
EndedAtMs int64 `json:"endedAtMs,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
PreviewState string `json:"previewState,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
TS int64 `json:"ts"`
}
type ssePublishItem struct {
EventName string
Data []byte
}
func visibleJobEventsJSON() []ssePublishItem {
nowTs := time.Now().UnixMilli()
out := make([]ssePublishItem, 0, 64)
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil || j.Hidden {
continue
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
continue
}
payload := jobEvent{
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
TS: nowTs,
}
if sm := sseStoredModelForJob(j); sm != nil {
payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus))
payload.IsOnline = sm.IsOnline
payload.ModelImageURL = strings.TrimSpace(sm.ImageURL)
payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL)
}
if j.EndedAt != nil {
payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano)
payload.EndedAtMs = j.EndedAtMs
}
b, err := json.Marshal(payload)
if err != nil {
continue
}
out = append(out, ssePublishItem{
EventName: eventName,
Data: b,
})
}
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) {
if j == nil || j.Hidden {
return
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
return
}
payload := jobEvent{
Type: "job_upsert",
Model: eventName,
JobID: j.ID,
Status: j.Status,
Phase: j.Phase,
Progress: j.Progress,
SourceURL: j.SourceURL,
Output: j.Output,
StartedAt: j.StartedAt.Format(time.RFC3339Nano),
StartedAtMs: j.StartedAtMs,
EndedAtMs: j.EndedAtMs,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
TS: time.Now().UnixMilli(),
}
if sm := sseStoredModelForJob(j); sm != nil {
payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus))
payload.IsOnline = sm.IsOnline
payload.ModelImageURL = strings.TrimSpace(sm.ImageURL)
payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL)
}
if j.EndedAt != nil {
payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano)
}
b, _ := json.Marshal(payload)
publishSSE(eventName, b)
}
func publishJobRemove(j *RecordJob) {
if j == nil || j.Hidden {
return
}
eventName := sseModelEventNameForJob(j)
if eventName == "" {
return
}
b, _ := json.Marshal(jobEvent{
Type: "job_remove",
Model: eventName,
JobID: j.ID,
TS: time.Now().UnixMilli(),
})
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 {
if j == nil {
return ""
}
src := strings.TrimSpace(j.SourceURL)
switch detectProvider(src) {
case "chaturbate":
if u := strings.TrimSpace(extractUsername(src)); u != "" {
return strings.ToLower(u)
}
case "mfc":
if u := strings.TrimSpace(extractMFCUsername(src)); u != "" {
return strings.ToLower(u)
}
}
return ""
}
func sseStoredModelForJob(j *RecordJob) *StoredModel {
if j == nil || cbModelStore == nil {
return nil
}
src := strings.TrimSpace(j.SourceURL)
if src == "" {
return nil
}
host := ""
modelKey := ""
switch detectProvider(src) {
case "chaturbate":
host = "chaturbate.com"
modelKey = strings.ToLower(strings.TrimSpace(extractUsername(src)))
default:
return nil
}
if host == "" || modelKey == "" {
return nil
}
m, ok := cbModelStore.GetByHostAndModelKey(host, modelKey)
if !ok {
return nil
}
return m
}
func initSSE() { func initSSE() {
srv := sse.New() srv := sse.New()
srv.SplitData = true srv.SplitData = true
// ✅ Nur noch EIN Stream
stream := srv.CreateStream("events") stream := srv.CreateStream("events")
stream.AutoReplay = false stream.AutoReplay = false
@ -32,25 +308,6 @@ func initSSE() {
server: srv, server: srv,
} }
// Debounced broadcaster (jobs)
go func() {
for range recordJobsNotify {
time.Sleep(40 * time.Millisecond)
for {
select {
case <-recordJobsNotify:
default:
goto SEND
}
}
SEND:
b := jobsSnapshotJSON()
if len(b) > 0 {
publishSSE("jobs", b)
}
}
}()
// Debounced broadcaster (done changed) // Debounced broadcaster (done changed)
go func() { go func() {
for range doneNotify { for range doneNotify {
@ -91,6 +348,53 @@ func initSSE() {
} }
} }
}() }()
// Per running job: 1 SSE event per second
go func() {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
for range t.C {
events := visibleJobEventsJSON()
for _, ev := range events {
if len(ev.Data) == 0 || ev.EventName == "" {
continue
}
publishSSE(ev.EventName, ev.Data)
}
}
}()
// 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) {
@ -113,8 +417,7 @@ var (
doneNotify = make(chan struct{}, 1) doneNotify = make(chan struct{}, 1)
doneSeq uint64 doneSeq uint64
recordJobsNotify = make(chan struct{}, 1) assetsNotify = make(chan struct{}, 1)
assetsNotify = make(chan struct{}, 1)
) )
func notifyDoneChanged() { func notifyDoneChanged() {
@ -124,13 +427,6 @@ func notifyDoneChanged() {
} }
} }
func notifyJobsChanged() {
select {
case recordJobsNotify <- struct{}{}:
default:
}
}
func notifyAssetsChanged() { func notifyAssetsChanged() {
select { select {
case assetsNotify <- struct{}{}: case assetsNotify <- struct{}{}:
@ -192,22 +488,6 @@ func eventsStream(w http.ResponseWriter, r *http.Request) {
sseApp.server.ServeHTTP(w, r2) sseApp.server.ServeHTTP(w, r2)
} }
// -------------------- optional compatibility handlers --------------------
// Falls du alte Routen noch kurz behalten willst, zeigen sie einfach
// auf denselben Unified-Stream.
func recordStream(w http.ResponseWriter, r *http.Request) {
eventsStream(w, r)
}
func doneStream(w http.ResponseWriter, r *http.Request) {
eventsStream(w, r)
}
func assetsStream(w http.ResponseWriter, r *http.Request) {
eventsStream(w, r)
}
// -------------------- optional helper -------------------- // -------------------- optional helper --------------------
func publishRawSSE(eventName string, buf *bytes.Buffer) { func publishRawSSE(eventName string, buf *bytes.Buffer) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-jlVIND2Y.js"></script> <script type="module" crossorigin src="/assets/index-z2cKWgjr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0pbgV48.css"> <link rel="stylesheet" crossorigin href="/assets/index-C6R3TW-y.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,7 @@ type Props = {
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 }>
roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void onStopJob: (id: string) => void
blurPreviews?: boolean blurPreviews?: boolean
@ -91,14 +92,35 @@ const normalizeRoomStatus = (v: unknown): string => {
case 'offline': case 'offline':
return 'Offline' return 'Offline'
default: default:
return 'Offline' return 'Unknown'
} }
} }
const roomStatusOfJob = (job: RecordJob): string => { const modelKeyFromJob = (job: RecordJob): string => {
const j = job as any const j = job as any
const raw = const rawUrl = String(j.sourceUrl ?? '').trim()
if (rawUrl) {
const m = rawUrl.match(/chaturbate\.com\/([^/?#]+)/i)
if (m?.[1]) {
try {
return decodeURIComponent(m[1]).trim().toLowerCase()
} catch {
return String(m[1]).trim().toLowerCase()
}
}
}
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>
): string => {
const j = job as any
const rawFromJob =
j.currentShow ?? j.currentShow ??
j.roomStatus ?? j.roomStatus ??
j.modelStatus ?? j.modelStatus ??
@ -108,10 +130,19 @@ const roomStatusOfJob = (job: RecordJob): string => {
j.model?.roomStatus ?? j.model?.roomStatus ??
j.model?.status ?? j.model?.status ??
j.room?.currentShow ?? j.room?.currentShow ??
j.room?.status ?? j.room?.status
''
return normalizeRoomStatus(raw) if (rawFromJob != null && String(rawFromJob).trim() !== '') {
return normalizeRoomStatus(rawFromJob)
}
const modelKey = modelKeyFromJob(job)
if (modelKey && roomStatusByModelKey?.[modelKey]) {
return normalizeRoomStatus(roomStatusByModelKey[modelKey])
}
return 'Unknown'
} }
const roomStatusTone = (status: string): string => { const roomStatusTone = (status: string): string => {
@ -125,6 +156,8 @@ const roomStatusTone = (status: string): string => {
case 'Away': case 'Away':
return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25' return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
case 'Offline': case 'Offline':
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
case 'Unknown':
default: default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
} }
@ -223,16 +256,6 @@ const phaseLabel = (p?: string) => {
} }
} }
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
function postWorkLabel( function postWorkLabel(
job: RecordJob, job: RecordJob,
override?: { pos?: number; total?: number } override?: { pos?: number; total?: number }
@ -344,6 +367,7 @@ function DownloadsCardRow({
nowMs, nowMs,
blurPreviews, blurPreviews,
modelsByKey, modelsByKey,
roomStatusByModelKey,
stopRequestedIds, stopRequestedIds,
postworkInfoOf, postworkInfoOf,
markStopRequested, markStopRequested,
@ -357,6 +381,7 @@ function DownloadsCardRow({
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 }>
roomStatusByModelKey: Record<string, string>
stopRequestedIds: Record<string, true> stopRequestedIds: Record<string, true>
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
@ -470,7 +495,7 @@ function DownloadsCardRow({
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) const roomStatus = roomStatusOfJob(j, roomStatusByModelKey)
let phaseText = phase ? (phaseLabel(phase) || phase) : '' let phaseText = phase ? (phaseLabel(phase) || phase) : ''
@ -537,6 +562,7 @@ function DownloadsCardRow({
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
blur={blurPreviews} blur={blurPreviews}
roomStatus={roomStatus}
alignStartAt={j.startedAt} alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null} alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000} alignEveryMs={10_000}
@ -789,6 +815,7 @@ export default function Downloads({
onToggleWatch, onToggleWatch,
onAddToDownloads, onAddToDownloads,
modelsByKey = {}, modelsByKey = {},
roomStatusByModelKey = {},
blurPreviews blurPreviews
}: Props) { }: Props) {
@ -1025,6 +1052,7 @@ export default function Downloads({
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
blur={blurPreviews} blur={blurPreviews}
roomStatus={roomStatusOfJob(j, roomStatusByModelKey)}
alignStartAt={j.startedAt} alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null} alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000} alignEveryMs={10_000}
@ -1080,7 +1108,7 @@ 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) const roomStatus = roomStatusOfJob(j, roomStatusByModelKey)
return ( return (
<> <>
@ -1276,7 +1304,7 @@ export default function Downloads({
}, },
}, },
] ]
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf]) }, [blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds, postworkInfoOf])
const downloadJobRows = useMemo<DownloadRow[]>(() => { const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs const list = jobs
@ -1455,6 +1483,7 @@ export default function Downloads({
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
@ -1475,11 +1504,12 @@ export default function Downloads({
</div> </div>
{postworkRows.map((r) => ( {postworkRows.map((r) => (
<DownloadsCardRow <DownloadsCardRow
key={`pw:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`} key={`dl:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
r={r} r={r}
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}
@ -1500,11 +1530,12 @@ export default function Downloads({
</div> </div>
{pendingRows.map((r) => ( {pendingRows.map((r) => (
<DownloadsCardRow <DownloadsCardRow
key={`wa:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`} key={`dl:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
r={r} r={r}
nowMs={nowMs} nowMs={nowMs}
blurPreviews={blurPreviews} blurPreviews={blurPreviews}
modelsByKey={modelsByKey} modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf} postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds} stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested} markStopRequested={markStopRequested}

View File

@ -9,14 +9,40 @@ export default function LiveVideo({
src, src,
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
className, className,
roomStatus,
}: { }: {
src: string src: string
muted?: boolean muted?: boolean
className?: string className?: string
roomStatus?: string
}) { }) {
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' | 'offline' | null>(null) const [brokenReason, setBrokenReason] = useState<'private' | 'hidden' | 'away' | 'offline' | null>(null)
const normalizeBrokenReason = (status?: string): 'private' | 'hidden' | 'away' | 'offline' => {
const s = String(status ?? '').trim().toLowerCase()
if (s === 'private') return 'private'
if (s === 'hidden') return 'hidden'
if (s === 'away') return 'away'
return 'offline'
}
const brokenMessage = (reason: 'private' | 'hidden' | 'away' | 'offline' | null): string => {
switch (reason) {
case 'hidden':
return 'Cam is hidden'
case 'away':
return 'Model is away'
case 'private':
return 'Private show in progress.'
case 'offline':
return 'Model is offline'
default:
return 'Live video unavailable'
}
}
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -51,24 +77,33 @@ export default function LiveVideo({
} }
video.addEventListener('timeupdate', onTime) video.addEventListener('timeupdate', onTime)
let stalledRetries = 0
watchdogTimer = window.setInterval(() => { watchdogTimer = window.setInterval(() => {
if (cancelled) return if (cancelled) return
// wenn nicht paused, aber 12s keine timeupdate => neu verbinden
// wenn nicht paused, aber 12s kein Fortschritt -> reconnect versuchen
if (!video.paused && Date.now() - lastT > 12_000) { if (!video.paused && Date.now() - lastT > 12_000) {
hardReset() if (stalledRetries < 1) {
video.src = src stalledRetries += 1
video.load() hardReset()
video.play().catch(() => {}) video.src = src
lastT = Date.now() video.load()
video.play().catch(() => {})
lastT = Date.now()
return
}
setBroken(true)
setBrokenReason(normalizeBrokenReason(roomStatus))
} }
}, 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 = () => {
// best effort: 403/404 kannst du im Backend zusätzlich über Query/JSON o.ä. signalisieren,
// aber hier: generic broken
setBroken(true) setBroken(true)
setBrokenReason(normalizeBrokenReason(roomStatus))
} }
video.addEventListener('error', onError) video.addEventListener('error', onError)
@ -80,12 +115,14 @@ export default function LiveVideo({
video.removeEventListener('error', onError) video.removeEventListener('error', onError)
hardReset() hardReset()
} }
}, [src, muted]) }, [src, muted, roomStatus])
if (broken) { if (broken) {
return ( return (
<div className="text-xs text-gray-400 italic"> <div className="grid h-full w-full place-items-center bg-black/80 px-4 text-center">
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : ''} <div className="text-sm font-medium text-white">
{brokenMessage(brokenReason)}
</div>
</div> </div>
) )
} }

View File

@ -13,6 +13,7 @@ type Props = {
blur?: boolean blur?: boolean
className?: string className?: string
fit?: 'cover' | 'contain' fit?: 'cover' | 'contain'
roomStatus?: string
alignStartAt?: string | number | Date alignStartAt?: string | number | Date
alignEndAt?: string | number | Date | null alignEndAt?: string | number | Date | null
@ -32,6 +33,7 @@ export default function ModelPreview({
autoTickMs = 10_000, autoTickMs = 10_000,
blur = false, blur = false,
className, className,
roomStatus,
alignStartAt, alignStartAt,
alignEndAt = null, alignEndAt = null,
alignEveryMs, alignEveryMs,
@ -269,7 +271,12 @@ export default function ModelPreview({
}} }}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<LiveVideo src={hq} muted={true} className="w-full h-full object-contain object-bottom relative z-0" /> <LiveVideo
src={hq}
muted={true}
roomStatus={roomStatus}
className="w-full h-full object-contain object-bottom relative z-0"
/>
<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" />

View File

@ -501,6 +501,17 @@ export default function ModelsTab() {
return () => window.removeEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any)
}, [refresh]) }, [refresh])
React.useEffect(() => {
const onDbChanged = () => {
setPage(1)
setModels([])
void refresh()
}
window.addEventListener('models-db-changed', onDbChanged as any)
return () => window.removeEventListener('models-db-changed', onDbChanged as any)
}, [refresh])
React.useEffect(() => { React.useEffect(() => {
const raw = input.trim() const raw = input.trim()
if (!raw) { if (!raw) {

View File

@ -81,6 +81,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null) const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const assetsAbortRef = useRef<AbortController | null>(null) const assetsAbortRef = useRef<AbortController | null>(null)
const [dbModalOpen, setDbModalOpen] = useState(false) const [dbModalOpen, setDbModalOpen] = useState(false)
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
const now = Date.now() const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now const saveSucceeded = saveSuccessUntilMs > now
@ -184,6 +185,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB, lowDiskPauseBelowGB: (data as any).lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB,
enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications, enableNotifications: (data as any).enableNotifications ?? DEFAULTS.enableNotifications,
}) })
setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim())
}) })
.catch(() => { .catch(() => {
// backend evtl. noch alt -> defaults lassen // backend evtl. noch alt -> defaults lassen
@ -252,6 +254,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const doneDir = value.doneDir.trim() const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim() const ffmpegPath = (value.ffmpegPath ?? '').trim()
const databaseUrl = String((value as any).databaseUrl ?? '').trim() const databaseUrl = String((value as any).databaseUrl ?? '').trim()
const hadPendingDbPassword = Boolean((pendingDbPassword || '').trim())
if (!recordDir || !doneDir) { if (!recordDir || !doneDir) {
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.') setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
@ -319,6 +322,19 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
saveSuccessTimerRef.current = null saveSuccessTimerRef.current = null
}, 2500) }, 2500)
window.dispatchEvent(new CustomEvent('recorder-settings-updated')) window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
const databaseUrlChanged =
databaseUrl !== loadedDatabaseUrl || hadPendingDbPassword
if (databaseUrlChanged) {
window.dispatchEvent(
new CustomEvent('models-db-changed', {
detail: { databaseUrl },
})
)
setLoadedDatabaseUrl(databaseUrl)
setPendingDbPassword('')
}
} catch (e: any) { } catch (e: any) {
setSaveSuccessUntilMs(0) setSaveSuccessUntilMs(0)
if (saveSuccessTimerRef.current != null) { if (saveSuccessTimerRef.current != null) {

View File

@ -1,191 +0,0 @@
// frontend/src/lib/chaturbateOnlinePoller.ts
export type ChaturbateOnlineRoom = {
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
export type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
total?: number
}
type OnlineState = ChaturbateOnlineResponse
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
function dedupeRooms(rooms: ChaturbateOnlineRoom[]): ChaturbateOnlineRoom[] {
const seen = new Set<string>()
const out: ChaturbateOnlineRoom[] = []
for (const r of rooms) {
const u = String(r?.username ?? '').trim().toLowerCase()
if (!u || seen.has(u)) continue
seen.add(u)
out.push(r)
}
return out
}
export function startChaturbateOnlinePolling(opts: {
getModels: () => string[]
getShow: () => string[]
onData: (data: OnlineState) => void
intervalMs?: number
// ✅ NEU: wenn getModels() leer ist, trotzdem einmal call machen (für "ALL online")
fetchAllWhenNoModels?: boolean
/** Optional: wird bei Fehlern aufgerufen (für Debug) */
onError?: (err: unknown) => void
}) {
const baseIntervalMs = opts.intervalMs ?? 5000
let timer: number | null = null
let inFlight: AbortController | null = null
let lastKey = ''
let lastResult: OnlineState | null = null
let stopped = false
const clearTimer = () => {
if (timer != null) {
window.clearTimeout(timer)
timer = null
}
}
const closeInFlight = () => {
if (inFlight) {
try {
inFlight.abort()
} catch {}
inFlight = null
}
}
const schedule = (ms: number) => {
if (stopped) return
clearTimer()
timer = window.setTimeout(() => void tick(), ms)
}
const tick = async () => {
if (stopped) return
try {
const models = (opts.getModels?.() ?? [])
.map((x) => String(x || '').trim())
.filter(Boolean)
const showRaw = (opts.getShow?.() ?? [])
.map((x) => String(x || '').trim())
.filter(Boolean)
// stabilisieren
const show = showRaw.slice().sort()
const modelsSorted = models.slice().sort()
// ✅ ALL-mode, wenn keine Models und Option aktiv
const isAllMode = modelsSorted.length === 0 && Boolean(opts.fetchAllWhenNoModels)
// keine Models -> normalerweise rooms leeren (enabled nicht neu erfinden)
if (modelsSorted.length === 0 && !isAllMode) {
closeInFlight()
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
lastResult = empty
opts.onData(empty)
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
return
}
// ✅ In ALL-mode senden wir q:[] (1 Request). Sonst normale Liste.
const modelsForRequest = isAllMode ? [] : modelsSorted
const key = `${show.join(',')}|${isAllMode ? '__ALL__' : modelsForRequest.join(',')}`
const requestKey = key
lastKey = key
// dedupe / cancel previous
closeInFlight()
const controller = new AbortController()
inFlight = controller
const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200300 nehmen
// ✅ ALL-mode: genau ein Part mit [] schicken
const parts = isAllMode ? [[]] : chunk(modelsForRequest, CHUNK_SIZE)
let mergedRooms: ChaturbateOnlineRoom[] = []
let mergedEnabled = false
let mergedTotal = 0
let hadAnyOk = false
for (const part of parts) {
if (controller.signal.aborted) return
if (requestKey !== lastKey) return
if (stopped) return
const res = await fetch('/api/chaturbate/online', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: part, show, refresh: false }),
signal: controller.signal,
cache: 'no-store',
})
if (!res.ok) continue
hadAnyOk = true
const data = (await res.json()) as OnlineState
mergedEnabled = mergedEnabled || Boolean(data?.enabled)
mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : []))
// ✅ NEU: total mergen (Backend liefert Gesamtzahl)
const t = Number((data as any)?.total ?? 0)
if (Number.isFinite(t) && t > mergedTotal) mergedTotal = t
}
if (!hadAnyOk) {
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
return
}
const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms), total: mergedTotal }
if (controller.signal.aborted) return
if (requestKey !== lastKey) return
if (stopped) return
lastResult = merged
opts.onData(merged)
} catch (e: any) {
if (e?.name === 'AbortError') return
opts.onError?.(e)
} finally {
// ✅ adaptive backoff: hidden tab = viel seltener pollen
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs)
}
}
// sofort einmal
void tick()
// stop function
return () => {
stopped = true
clearTimer()
closeInFlight()
}
}