sse update
This commit is contained in:
parent
6f12d3c2b1
commit
ceb310a428
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(rooms) > 0 {
|
||||||
|
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
||||||
}
|
}
|
||||||
}(append([]ChaturbateRoom(nil), rooms...), fetchedAtNow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
if inPostwork {
|
|
||||||
r := rangeFor(phaseLower)
|
r := rangeFor(phaseLower)
|
||||||
if r.end >= r.start {
|
|
||||||
if pct >= r.start && pct <= r.end {
|
|
||||||
mapped = pct
|
|
||||||
} else {
|
|
||||||
width := float64(r.end - r.start)
|
width := float64(r.end - r.start)
|
||||||
|
|
||||||
|
mapped := r.start
|
||||||
|
if width > 0 {
|
||||||
mapped = r.start + int(math.Round((float64(pct)/100.0)*width))
|
mapped = r.start + int(math.Round((float64(pct)/100.0)*width))
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapped < r.start {
|
if mapped < r.start {
|
||||||
mapped = r.start
|
mapped = r.start
|
||||||
}
|
}
|
||||||
if mapped > r.end {
|
if mapped > r.end {
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) != "" {
|
||||||
|
|||||||
368
backend/sse.go
368
backend/sse.go
@ -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,7 +417,6 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -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) {
|
||||||
|
|||||||
1
backend/web/dist/assets/index-C6R3TW-y.css
vendored
Normal file
1
backend/web/dist/assets/index-C6R3TW-y.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-D0pbgV48.css
vendored
1
backend/web/dist/assets/index-D0pbgV48.css
vendored
File diff suppressed because one or more lines are too long
449
backend/web/dist/assets/index-jlVIND2Y.js
vendored
449
backend/web/dist/assets/index-jlVIND2Y.js
vendored
File diff suppressed because one or more lines are too long
449
backend/web/dist/assets/index-z2cKWgjr.js
vendored
Normal file
449
backend/web/dist/assets/index-z2cKWgjr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -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
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
if (stalledRetries < 1) {
|
||||||
|
stalledRetries += 1
|
||||||
hardReset()
|
hardReset()
|
||||||
video.src = src
|
video.src = src
|
||||||
video.load()
|
video.load()
|
||||||
video.play().catch(() => {})
|
video.play().catch(() => {})
|
||||||
lastT = Date.now()
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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: 200–300 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user