diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go new file mode 100644 index 0000000..1dfd3e9 --- /dev/null +++ b/backend/pending_autostart.go @@ -0,0 +1,253 @@ +// backend\pending_autostart.go + +package main + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +type PendingAutoStartItem struct { + ModelKey string `json:"modelKey"` + URL string `json:"url"` + Mode string `json:"mode,omitempty"` // wait_public | probe_retry + NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry + CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown + ImageURL string `json:"imageUrl,omitempty"` +} + +type PendingAutoStartResponse struct { + Items []PendingAutoStartItem `json:"items"` +} + +type pendingAutoStartFile struct { + Items []PendingAutoStartItem `json:"items"` +} + +var pendingAutoStartMu sync.Mutex + +func normalizePendingModeServer(v string) string { + if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" { + return "probe_retry" + } + return "wait_public" +} + +func normalizePendingShowServer(v string) string { + switch strings.TrimSpace(strings.ToLower(v)) { + case "public": + return "public" + case "private": + return "private" + case "hidden": + return "hidden" + case "away": + return "away" + case "offline": + return "offline" + default: + return "unknown" + } +} + +func safeUserKeyForFile(v string) string { + re := regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + return re.ReplaceAllString(v, "_") +} + +func pendingAutoStartFilePath(userKey string) string { + return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json") +} + +func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { + path := pendingAutoStartFilePath(userKey) + + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []PendingAutoStartItem{}, nil + } + return nil, err + } + + var f pendingAutoStartFile + if err := json.Unmarshal(raw, &f); err != nil { + // kaputte Datei => lieber leer zurückgeben statt alles zu crashen + return []PendingAutoStartItem{}, nil + } + + if f.Items == nil { + f.Items = []PendingAutoStartItem{} + } + + // noch einmal sauber normalisieren + out := make([]PendingAutoStartItem, 0, len(f.Items)) + for _, it := range f.Items { + key := strings.ToLower(strings.TrimSpace(it.ModelKey)) + u := strings.TrimSpace(it.URL) + if key == "" || u == "" { + continue + } + + out = append(out, PendingAutoStartItem{ + ModelKey: key, + URL: u, + Mode: normalizePendingModeServer(it.Mode), + NextProbeAtMs: it.NextProbeAtMs, + CurrentShow: normalizePendingShowServer(it.CurrentShow), + ImageURL: strings.TrimSpace(it.ImageURL), + }) + } + + return out, nil +} + +func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error { + path := pendingAutoStartFilePath(userKey) + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + payload := pendingAutoStartFile{ + Items: items, + } + + raw, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o644); err != nil { + return err + } + + return os.Rename(tmp, path) +} + +func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.getSession(r) + if sess == nil || !sess.Authed || strings.TrimSpace(sess.User) == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + userKey := strings.ToLower(strings.TrimSpace(sess.User)) + + pendingAutoStartMu.Lock() + defer pendingAutoStartMu.Unlock() + + switch r.Method { + case http.MethodGet: + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(PendingAutoStartResponse{ + Items: items, + }) + return + + case http.MethodPost: + var in PendingAutoStartItem + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "invalid json body", http.StatusBadRequest) + return + } + + in.ModelKey = strings.ToLower(strings.TrimSpace(in.ModelKey)) + in.URL = strings.TrimSpace(in.URL) + in.Mode = normalizePendingModeServer(in.Mode) + in.CurrentShow = normalizePendingShowServer(in.CurrentShow) + in.ImageURL = strings.TrimSpace(in.ImageURL) + + if in.ModelKey == "" { + http.Error(w, "missing modelKey", http.StatusBadRequest) + return + } + if in.URL == "" { + http.Error(w, "missing url", http.StatusBadRequest) + return + } + + u, err := url.Parse(in.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + replaced := false + for i := range items { + if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == in.ModelKey { + items[i] = in + replaced = true + break + } + } + + if !replaced { + items = append(items, in) + } + + if err := savePendingAutoStartItems(userKey, items); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + return + + case http.MethodDelete: + modelKey := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("modelKey"))) + if modelKey == "" { + http.Error(w, "missing modelKey", http.StatusBadRequest) + return + } + + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + next := make([]PendingAutoStartItem, 0, len(items)) + for _, item := range items { + if strings.ToLower(strings.TrimSpace(item.ModelKey)) == modelKey { + continue + } + next = append(next, item) + } + + if err := savePendingAutoStartItems(userKey, next); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + return + + default: + w.Header().Set("Allow", "GET, POST, DELETE") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + } +} diff --git a/backend/routes.go b/backend/routes.go index c823f87..1539601 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -43,6 +43,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/settings/browse", settingsBrowse) api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler) + api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth)) api.HandleFunc("/api/record", startRecordingFromRequest) api.HandleFunc("/api/record/status", recordStatus) api.HandleFunc("/api/record/stop", recordStop) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18065ac..5ce0bb9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -202,6 +202,21 @@ type AutostartState = { pausedByDisk?: boolean } +type PendingAutoStartMode = 'wait_public' | 'probe_retry' + +type PendingAutoStartItem = { + modelKey: string + url: string + mode?: PendingAutoStartMode + nextProbeAtMs?: number + currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' + imageUrl?: string +} + +type PendingAutoStartResponse = { + items?: PendingAutoStartItem[] +} + function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { const s = String(v ?? '').trim().toLowerCase() @@ -530,6 +545,8 @@ export default function App() { const doneCountInFlightRef = useRef(false) const doneCountLastAtRef = useRef(0) + const pendingLoadInFlightRef = useRef(false) + const pendingLoadLastAtRef = useRef(0) const [playerModel, setPlayerModel] = useState(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) @@ -560,6 +577,18 @@ export default function App() { const [pendingWatchedRooms, setPendingWatchedRooms] = useState([]) const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState>({}) + const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState>({}) + const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState>({}) + + const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey) + useEffect(() => { + pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey + }, [pendingAutoStartModeByKey]) + + const pendingBlindRetryAtByKeyRef = useRef(pendingBlindRetryAtByKey) + useEffect(() => { + pendingBlindRetryAtByKeyRef.current = pendingBlindRetryAtByKey + }, [pendingBlindRetryAtByKey]) // "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet) const busyRef = useRef(false) @@ -650,18 +679,21 @@ export default function App() { startedToastByJobIdRef.current = {} jobsInitDoneRef.current = false + setPendingWatchedRooms([]) setPendingAutoStartByKey({}) + setPendingAutoStartModeByKey({}) + setPendingBlindRetryAtByKey({}) + + pendingAutoStartByKeyRef.current = {} + pendingAutoStartModeByKeyRef.current = {} + pendingBlindRetryAtByKeyRef.current = {} // optional: URL-Feld leeren setSourceUrl('') } }, []) - useEffect(() => { - void checkAuth() - }, [checkAuth]) - const isCookieGateError = (msg: string) => { const m = (msg || '').toLowerCase() return ( @@ -869,6 +901,62 @@ export default function App() { } }, [includeKeep]) + const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => { + const force = Boolean(opts?.force) + const now = Date.now() + + if (pendingLoadInFlightRef.current) return + if (!force && now - pendingLoadLastAtRef.current < 1500) return + + pendingLoadInFlightRef.current = true + pendingLoadLastAtRef.current = now + + try { + const data = await apiJSON('/api/pending-autostart', { + cache: 'no-store' as any, + }) + + const items = Array.isArray(data?.items) ? data.items : [] + + const nextByKey: Record = {} + const nextModeByKey: Record = {} + const nextBlindRetryAtByKey: Record = {} + + for (const item of items) { + const keyLower = String(item?.modelKey ?? '').trim().toLowerCase() + const norm0 = normalizeHttpUrl(String(item?.url ?? '')) + const mode: PendingAutoStartMode = + String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public' + + if (!keyLower || !norm0) continue + + const norm = canonicalizeProviderUrl(norm0) + + nextByKey[keyLower] = norm + nextModeByKey[keyLower] = mode + + if (mode === 'probe_retry') { + const ts = Number(item?.nextProbeAtMs ?? 0) + nextBlindRetryAtByKey[keyLower] = + Number.isFinite(ts) && ts > 0 ? ts : Date.now() + 60_000 + } + } + + setPendingAutoStartByKey(nextByKey) + pendingAutoStartByKeyRef.current = nextByKey + + setPendingAutoStartModeByKey(nextModeByKey) + pendingAutoStartModeByKeyRef.current = nextModeByKey + + setPendingBlindRetryAtByKey(nextBlindRetryAtByKey) + pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey + } catch { + // ignore + } finally { + pendingLoadInFlightRef.current = false + } + }, []) + const loadJobs = useCallback(async () => { try { const res = await fetch('/api/record/list', { cache: 'no-store' as any }) @@ -944,6 +1032,15 @@ export default function App() { } }, []) + useEffect(() => { + void checkAuth() + }, [checkAuth]) + + useEffect(() => { + if (!authed) return + void loadPendingAutoStarts() + }, [authed, loadPendingAutoStarts]) + useEffect(() => { try { window.localStorage.setItem(DONE_SORT_KEY, doneSort) @@ -1246,13 +1343,21 @@ export default function App() { const model = modelsByKeyRef.current[keyLower] if (!model?.watching) { - lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw) + const nextStatus = normalizeRoomStatusText(nextStatusRaw) + + // unknown nicht als "letzten echten Status" speichern + if (nextStatus !== 'unknown') { + lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus + } return } const nextStatus = normalizeRoomStatusText(nextStatusRaw) const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) + // unknown niemals melden und auch nicht als letzten echten Status überschreiben + if (nextStatus === 'unknown') return + // keine Änderung if (prevStatus === nextStatus) return @@ -1283,7 +1388,7 @@ export default function App() { notifyRef.current?.info(title, message, { imageUrl, imageAlt: displayName, - durationMs: 5000, + durationMs: 3000, onClick: () => { window.dispatchEvent( new CustomEvent('open-model-details', { @@ -1328,6 +1433,17 @@ export default function App() { if (msg?.type === 'job_upsert') { if (modelKey) { + const statusLower = String(msg?.status ?? '').trim().toLowerCase() + const phaseLower = String(msg?.phase ?? '').trim().toLowerCase() + + const isActiveVisibleRecording = + statusLower === 'running' && + (phaseLower === '' || phaseLower === 'recording') + + if (isActiveVisibleRecording) { + removePendingAutoStart(modelKey) + } + const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() if (roomStatus) { @@ -1484,7 +1600,8 @@ export default function App() { type StartQueueItem = { url: string silent: boolean - pendingKeyLower?: string // wenn aus pendingAutoStartByKey kommt + pendingKeyLower?: string + hidden?: boolean } const startQueueRef = useRef([]) @@ -1498,11 +1615,13 @@ export default function App() { busyRef.current = v }, []) - async function doStartNow(normUrl: string, silent: boolean): Promise { - + async function doStartNow( + normUrl: string, + silent: boolean, + opts?: { hidden?: boolean } + ): Promise { normUrl = canonicalizeProviderUrl(normUrl) - // ✅ Duplicate-running guard (wie vorher) const alreadyRunning = jobsRef.current.some((j) => { if (String(j.status || '').toLowerCase() !== 'running') return false if ((j as any).endedAt) return false @@ -1533,23 +1652,29 @@ export default function App() { const created = await apiJSON('/api/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: normUrl, cookie: cookieString }), + body: JSON.stringify({ + url: normUrl, + cookie: cookieString, + hidden: Boolean(opts?.hidden), + }), }) - if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true + if (created?.id && !opts?.hidden) { + startedToastByJobIdRef.current[String(created.id)] = true + } - // UI sofort aktualisieren (optional) - setJobs((prev) => { - const next = upsertJobById(prev, created) - jobsRef.current = next - return next - }) + if (!(opts?.hidden || Boolean((created as any)?.hidden))) { + setJobs((prev) => { + const next = upsertJobById(prev, created) + jobsRef.current = next + return next + }) + } return true } catch (e: any) { const msg = e?.message ?? String(e) - // ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis if (isCookieGateError(msg)) { showMissingCookiesMessage({ silent }) return false @@ -1569,7 +1694,7 @@ export default function App() { void (async () => { try { - const ok = await doStartNow(next.url, next.silent) + const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden }) // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen if (ok && next.pendingKeyLower) { @@ -1706,26 +1831,63 @@ export default function App() { return () => window.removeEventListener('models-changed', onChanged as any) }, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) - const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => { - if (!keyLower) return + const queuePendingAutoStart = useCallback(( + keyLower: string, + url: string, + show?: string, + imageUrl?: string, + opts?: { + mode?: PendingAutoStartMode + nextProbeAtMs?: number + } + ) => { + const key = String(keyLower ?? '').trim().toLowerCase() + const norm0 = normalizeHttpUrl(String(url ?? '')) + if (!key || !norm0) return + + const norm = canonicalizeProviderUrl(norm0) + const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public' + const nextProbeAtMs = + mode === 'probe_retry' + ? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000)) + : undefined setPendingAutoStartByKey((prev) => { - const next = { ...(prev || {}), [keyLower]: url } + const next = { ...(prev || {}), [key]: norm } pendingAutoStartByKeyRef.current = next return next }) + setPendingAutoStartModeByKey((prev) => { + const next = { ...(prev || {}), [key]: mode } + pendingAutoStartModeByKeyRef.current = next + return next + }) + + setPendingBlindRetryAtByKey((prev) => { + const next = { ...(prev || {}) } + + if (mode === 'probe_retry') { + next[key] = Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : Date.now() + 60_000 + } else { + delete next[key] + } + + pendingBlindRetryAtByKeyRef.current = next + return next + }) + setPendingWatchedRooms((prev) => { const nextItem: PendingWatchedRoom = { - id: keyLower, - modelKey: keyLower, - url, + id: key, + modelKey: key, + url: norm, currentShow: normalizePendingShow(show), imageUrl: imageUrl || undefined, } const idx = prev.findIndex( - (x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower + (x) => String(x.modelKey ?? '').trim().toLowerCase() === key ) if (idx >= 0) { @@ -1736,21 +1898,77 @@ export default function App() { return [nextItem, ...prev] }) + + const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim() + const prevMode = String(pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public').trim() + const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0) + const nextShow = normalizePendingShow(show) + const nextRetryAt = mode === 'probe_retry' + ? (Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 0) + : 0 + + const sameUrl = prevUrl === norm + const sameMode = prevMode === mode + const sameRetryAt = + mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000 + + if (sameUrl && sameMode && sameRetryAt) { + return + } + + void fetch('/api/pending-autostart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modelKey: key, + url: norm, + mode, + nextProbeAtMs: mode === 'probe_retry' ? nextProbeAtMs : undefined, + currentShow: nextShow, + imageUrl: imageUrl || undefined, + }), + }).catch(() => {}) }, []) const removePendingAutoStart = useCallback((keyLower: string) => { - if (!keyLower) return + const key = String(keyLower ?? '').trim().toLowerCase() + if (!key) return + + const hadPending = + Boolean(pendingAutoStartByKeyRef.current[key]) || + Boolean(pendingAutoStartModeByKeyRef.current[key]) || + Boolean(pendingBlindRetryAtByKeyRef.current[key]) + + if (!hadPending) return setPendingAutoStartByKey((prev) => { const next = { ...(prev || {}) } - delete next[keyLower] + delete next[key] pendingAutoStartByKeyRef.current = next return next }) + setPendingAutoStartModeByKey((prev) => { + const next = { ...(prev || {}) } + delete next[key] + pendingAutoStartModeByKeyRef.current = next + return next + }) + + setPendingBlindRetryAtByKey((prev) => { + const next = { ...(prev || {}) } + delete next[key] + pendingBlindRetryAtByKeyRef.current = next + return next + }) + setPendingWatchedRooms((prev) => - prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower) + prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key) ) + + void fetch(`/api/pending-autostart?modelKey=${encodeURIComponent(key)}`, { + method: 'DELETE', + }).catch(() => {}) }, []) const selectedTabRef = useRef(selectedTab) @@ -1844,21 +2062,31 @@ export default function App() { return } - const next: PendingWatchedRoom[] = keys.map((rawKey) => { - const key = String(rawKey ?? '').trim().toLowerCase() - const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim() - const model = (modelsByKey as any)?.[key] ?? null + setPendingWatchedRooms((prev) => { + const prevByKey = new Map( + prev.map((x) => [String(x.modelKey ?? '').trim().toLowerCase(), x] as const) + ) - return { - id: key, - modelKey: key, - url, - currentShow: normalizePendingShow(model?.roomStatus), - imageUrl: String(model?.imageUrl ?? '').trim() || undefined, - } + const next: PendingWatchedRoom[] = keys.map((rawKey) => { + const key = String(rawKey ?? '').trim().toLowerCase() + const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim() + const model = (modelsByKey as any)?.[key] ?? null + const prevItem = prevByKey.get(key) + + return { + id: key, + modelKey: key, + url, + currentShow: normalizePendingShow(model?.roomStatus ?? prevItem?.currentShow), + imageUrl: + String(model?.imageUrl ?? '').trim() || + prevItem?.imageUrl || + undefined, + } + }) + + return next }) - - setPendingWatchedRooms(next) }, [pendingAutoStartByKey, modelsByKey]) useEffect(() => { @@ -1924,10 +2152,12 @@ export default function App() { } } + const modeMap = pendingAutoStartModeByKeyRef.current || {} + const retryAtMap = pendingBlindRetryAtByKeyRef.current || {} + for (const key of keys) { const snap = - byKey[key] ?? - { + byKey[key] ?? { show: 'unknown' as const, imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined, chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined, @@ -1942,6 +2172,8 @@ export default function App() { const url = String((pendingMap as any)?.[key] ?? '').trim() if (!url) continue + const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode + if (snap.show === 'public') { enqueueStart({ url, @@ -1951,12 +2183,39 @@ export default function App() { continue } - if (snap.show === 'offline') { - removePendingAutoStart(key) + if (mode === 'probe_retry') { + // Sobald API das Model als private/hidden/away kennt: + // nicht mehr blind probieren, sondern einfach auf public warten. + if ( + snap.show === 'private' || + snap.show === 'hidden' || + snap.show === 'away' + ) { + queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { + mode: 'wait_public', + }) + continue + } + + const nextProbeAtMs = Number(retryAtMap[key] ?? 0) + if (!busyRef.current && Date.now() >= nextProbeAtMs) { + enqueueStart({ + url, + silent: true, + hidden: true, + }) + + queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { + mode: 'probe_retry', + nextProbeAtMs: Date.now() + 60_000, + }) + } + continue } - // private / hidden / away / unknown bleiben in der Warteschlange + // mode === 'wait_public' + // private / hidden / away / offline / unknown => einfach warten } } catch { // ignore @@ -1965,12 +2224,20 @@ export default function App() { } } - const schedule = (ms: number) => { + const schedule = (ms?: number) => { if (cancelled) return + + const retryMap = pendingBlindRetryAtByKeyRef.current || {} + const hasProbeRetry = Object.keys(retryMap).length > 0 + + const nextMs = + ms ?? + (hasProbeRetry ? 1000 : (document.hidden ? 8000 : 3000)) + timer = window.setTimeout(async () => { await tick() - schedule(document.hidden ? 8000 : 3000) - }, ms) + schedule() + }, nextMs) } schedule(0) @@ -2101,7 +2368,10 @@ export default function App() { }) if (alreadyRunning) return true - // ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart + // ✅ Chaturbate-Startlogik mit Online-API: + // public -> direkt starten + // private/hidden/away -> wait_public + // offline/unknown -> probe_retry mit hidden start if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { try { const parsed = await apiJSON('/api/models/parse', { @@ -2116,14 +2386,13 @@ export default function App() { let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown' let resolvedImageUrl: string | undefined let resolvedChatRoomUrl: string | undefined + let foundInApi = false try { const onlineRes = await fetch('/api/chaturbate/online', { method: 'POST', cache: 'no-store', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q: [mkLower] }), }) @@ -2134,6 +2403,7 @@ export default function App() { : null if (room) { + foundInApi = true resolvedShow = normalizePendingShow(room.current_show) resolvedImageUrl = String(room.image_url ?? '').trim() || undefined resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined @@ -2149,27 +2419,47 @@ export default function App() { chatRoomUrl: resolvedChatRoomUrl, }) - if ( - resolvedShow === 'private' || - resolvedShow === 'hidden' || - resolvedShow === 'away' || - resolvedShow === 'unknown' + // 1) in API + public => sofort normal starten + if (foundInApi && resolvedShow === 'public') { + removePendingAutoStart(mkLower) + // unten normal weiter + } else if ( + foundInApi && + (resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away') ) { - queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl) + // 2) in API + nicht public => warten bis public + queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { + mode: 'wait_public', + }) + return true + } else { + // 3) nicht in API ODER offline/unknown => Blind-Probe jetzt starten + queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { + mode: 'probe_retry', + nextProbeAtMs: Date.now() + 60_000, + }) + + const ok = await doStartNow(norm, silent, { hidden: true }) + if (!ok) { + removePendingAutoStart(mkLower) + return false + } return true } - - if (resolvedShow === 'offline') { - removePendingAutoStart(mkLower) - return false - } - - // public => unten normal starten } } catch { const mkLower = providerKeyLowerFromUrl(norm) if (mkLower) { - queuePendingAutoStart(mkLower, norm, 'unknown') + queuePendingAutoStart(mkLower, norm, 'unknown', undefined, { + mode: 'probe_retry', + nextProbeAtMs: Date.now() + 60_000, + }) + + const ok = await doStartNow(norm, silent, { hidden: true }) + if (!ok) { + removePendingAutoStart(mkLower) + return false + } return true } } @@ -2566,7 +2856,7 @@ export default function App() { eventSourceRef.current = null modelEventNamesRef.current = new Set() } - }, [authed, loadJobs, loadDoneCount]) + }, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts]) useEffect(() => { const desired = new Set() diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 10a4e75..95f746e 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -559,6 +559,8 @@ export default function Downloads({ const [watchedBusy, setWatchedBusy] = useState(false) + const [thumbTick, setThumbTick] = useState(0) + const refreshWatchedState = useCallback(async () => { try { await onRefreshAutostartState?.() @@ -567,6 +569,29 @@ export default function Downloads({ } }, [onRefreshAutostartState]) + const [pageVisible, setPageVisible] = useState(!document.hidden) + + useEffect(() => { + const onVis = () => setPageVisible(!document.hidden) + document.addEventListener('visibilitychange', onVis) + return () => document.removeEventListener('visibilitychange', onVis) + }, []) + + useEffect(() => { + const hasLiveJobs = jobs.some((j) => { + if ((j as any).endedAt) return false + return String(j.status ?? '').trim().toLowerCase() === 'running' + }) + + if (!hasLiveJobs || !pageVisible) return + + const id = window.setInterval(() => { + setThumbTick((t) => t + 1) + }, 10000) + + return () => window.clearInterval(id) + }, [jobs, pageVisible]) + useEffect(() => { const nextPaused = Boolean(autostartState?.paused) const nextPausedByUser = Boolean(autostartState?.pausedByUser) @@ -922,6 +947,7 @@ export default function Downloads({ roomStatusByModelKey: Record @@ -421,6 +422,7 @@ function postWorkLabel( export default function DownloadsCardRow({ r, nowMs, + thumbTick, blurPreviews, modelsByKey, roomStatusByModelKey, @@ -673,6 +675,7 @@ export default function DownloadsCardRow({ (null) const [inView, setInView] = useState(false) - const [pageVisible, setPageVisible] = useState(!document.hidden) const [imgError, setImgError] = useState(false) + const [imgSrc, setImgSrc] = useState('') const [liveSrc, setLiveSrc] = useState('') const [hoverOpen, setHoverOpen] = useState(false) @@ -82,16 +80,19 @@ export default function ModelPreview({ const normalizedRoomStatus = String(roomStatus ?? '').trim().toLowerCase() const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' - - const [previewVersion, setPreviewVersion] = useState(0) - const [imgLoading, setImgLoading] = useState(false) const previewSrc = useMemo(() => { - if (!live) { - return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` + const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` + + if (!live) return base + + // thumbTick kommt zentral vom Parent + if (typeof thumbTick === 'number') { + return `${base}&v=${thumbTick}` } - return `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg&v=${previewVersion}` - }, [jobId, live, previewVersion]) + + return base + }, [jobId, live, thumbTick]) const fallbackImgSrc = useMemo(() => { const s = String(fallbackSrc ?? '').trim() @@ -100,26 +101,20 @@ export default function ModelPreview({ }, [fallbackSrc, jobId]) const liveStreamSrc = useMemo( - () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`, + () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1`, [jobId] ) useEffect(() => { - const onVis = () => setPageVisible(!document.hidden) - document.addEventListener('visibilitychange', onVis) - return () => document.removeEventListener('visibilitychange', onVis) - }, []) - - useEffect(() => { - if (!inView) return - setImgLoading(true) - }, [previewSrc, inView]) + setImgSrc(previewSrc) + setImgError(false) + }, [previewSrc]) useEffect(() => { const ac = new AbortController() const loadLive = async () => { - if (!hoverOpen) { + if (!live || !hoverOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) @@ -134,7 +129,7 @@ export default function ModelPreview({ try { // ✅ HLS-Refresh jetzt direkt über /api/preview/live const prepareRes = await fetch( - `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&prepare=1`, + `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`, { method: 'GET', cache: 'no-store', @@ -170,7 +165,7 @@ export default function ModelPreview({ return () => { ac.abort() } - }, [hoverOpen, jobId, liveStreamSrc]) + }, [hoverOpen, live, jobId, liveStreamSrc]) useEffect(() => { const el = rootRef.current @@ -193,25 +188,11 @@ export default function ModelPreview({ useEffect(() => { setImgError(false) + setImgSrc(previewSrc) setLiveSrc('') setLivePreparing(false) setLiveReady(false) - }, [jobId]) - - useEffect(() => { - if (!live) return - if (typeof thumbTick === 'number') return - if (!inView) return - if (!pageVisible) return - if (imgError) return - if (imgLoading) return - - const id = window.setTimeout(() => { - setPreviewVersion((x) => x + 1) - }, autoTickMs) - - return () => window.clearTimeout(id) - }, [live, thumbTick, inView, pageVisible, autoTickMs, imgError, imgLoading, previewVersion]) + }, [jobId, previewSrc]) return ( { - setImgLoading(false) setImgError(false) }} onError={() => { - setImgLoading(false) + const current = String(imgSrc || '').trim() + const fallback = String(fallbackImgSrc || '').trim() + + // Erst von preview.jpg auf Provider-Fallback wechseln + if (fallback && current !== fallback) { + setImgSrc(fallback) + return + } + + // Wenn selbst das Fallback nicht lädt: endgültig Fehler setImgError(true) }} /> ) : ( - +
) ) : (
diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx index 17d6745..55b5ba7 100644 --- a/frontend/src/components/ui/Player.tsx +++ b/frontend/src/components/ui/Player.tsx @@ -21,6 +21,23 @@ import Button from './Button' import { apiUrl, apiFetch } from '../../lib/api' import LiveVideo from './LiveVideo' import { formatResolution, formatFps } from './formatters' +import LoadingSpinner from './LoadingSpinner' + +function buildChaturbateCookieHeader(): string { + try { + const raw = window.localStorage.getItem('record_cookies') + if (!raw) return '' + + const obj = JSON.parse(raw) as Record + return Object.entries(obj ?? {}) + .map(([k, v]) => [String(k ?? '').trim(), String(v ?? '').trim()] as const) + .filter(([k, v]) => k && v) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + } catch { + return '' + } +} const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) @@ -312,6 +329,9 @@ export default function Player({ const [liveMuted, setLiveMuted] = React.useState(startMuted) const [liveVolume, setLiveVolume] = React.useState(startMuted ? 0 : 1) + const [livePreparing, setLivePreparing] = React.useState(false) + const [livePreparedSrc, setLivePreparedSrc] = React.useState('') + const [liveReady, setLiveReady] = React.useState(false) // ✅ Backend erwartet "id=" (nicht "name=") // running: echte job.id (jobs-map lookup) @@ -416,6 +436,65 @@ export default function Player({ const [previewSrc, setPreviewSrc] = React.useState(previewA) + React.useEffect(() => { + if (!isLive) { + setLivePreparing(false) + setLivePreparedSrc('') + setLiveReady(false) + return + } + + let cancelled = false + const ac = new AbortController() + + const prepareLive = async () => { + setLivePreparing(true) + setLiveReady(false) + setLivePreparedSrc('') + + const cookieHeader = buildChaturbateCookieHeader() + + try { + const prepareUrl = apiUrl( + `/api/preview/live?id=${encodeURIComponent(previewId)}&play=1&prepare=1` + ) + + const res = await apiFetch(prepareUrl, { + method: 'GET', + cache: 'no-store', + signal: ac.signal, + headers: cookieHeader + ? { 'X-Chaturbate-Cookie': cookieHeader } + : undefined, + }) + + if (cancelled || ac.signal.aborted) return + + await res.json().catch(() => null) + + if (cancelled || ac.signal.aborted) return + + setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`) + } catch { + if (cancelled || ac.signal.aborted) return + + // best effort: Stream trotzdem versuchen + setLivePreparedSrc(`${liveHlsSrc}&ts=${Date.now()}`) + } finally { + if (!cancelled && !ac.signal.aborted) { + setLivePreparing(false) + } + } + } + + void prepareLive() + + return () => { + cancelled = true + ac.abort() + } + }, [isLive, previewId, liveHlsSrc]) + React.useEffect(() => { setPreviewSrc(previewA) }, [previewA]) @@ -1706,9 +1785,15 @@ export default function Player({ {isLive ? (
{ + setLiveReady(false) + }} + onReady={() => { + setLiveReady(true) + }} onVolumeChange={(nextVolume, nextMuted) => { setLiveVolume(nextVolume) setLiveMuted(nextMuted) @@ -1716,6 +1801,15 @@ export default function Player({ className="w-full h-full object-cover object-center" /> + {(livePreparing || !liveReady) ? ( +
+ +
+ {livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'} +
+
+ ) : null} +