updated pending autostart

This commit is contained in:
Linrador 2026-04-07 15:18:49 +02:00
parent fbe9f306a3
commit f0541b9fe6
7 changed files with 773 additions and 120 deletions

View File

@ -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
}
}
}

View File

@ -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)

View File

@ -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<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
@ -560,6 +577,18 @@ export default function App() {
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})
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<PendingAutoStartResponse>('/api/pending-autostart', {
cache: 'no-store' as any,
})
const items = Array.isArray(data?.items) ? data.items : []
const nextByKey: Record<string, string> = {}
const nextModeByKey: Record<string, PendingAutoStartMode> = {}
const nextBlindRetryAtByKey: Record<string, number> = {}
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<StartQueueItem[]>([])
@ -1498,11 +1615,13 @@ export default function App() {
busyRef.current = v
}, [])
async function doStartNow(normUrl: string, silent: boolean): Promise<boolean> {
async function doStartNow(
normUrl: string,
silent: boolean,
opts?: { hidden?: boolean }
): Promise<boolean> {
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<RecordJob>('/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<ParsedModel>('/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<string>()

View File

@ -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({
<ModelPreview
jobId={j.id}
live={true}
thumbTick={thumbTick}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
j,
@ -1571,6 +1597,7 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
@ -1628,6 +1655,7 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
@ -1709,6 +1737,7 @@ export default function Downloads({
<DownloadsCardRow
r={r}
nowMs={nowMs}
thumbTick={thumbTick}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}

View File

@ -35,6 +35,7 @@ type ModelFlags = {
type Props = {
r: DownloadRow
nowMs: number
thumbTick?: number
blurPreviews?: boolean
modelsByKey: Record<string, ModelFlags>
roomStatusByModelKey: Record<string, string>
@ -421,6 +422,7 @@ function postWorkLabel(
export default function DownloadsCardRow({
r,
nowMs,
thumbTick,
blurPreviews,
modelsByKey,
roomStatusByModelKey,
@ -673,6 +675,7 @@ export default function DownloadsCardRow({
<ModelPreview
jobId={j.id}
live={true}
thumbTick={thumbTick}
blur={blurPreviews}
roomStatus={previewRoomStatusOfJob(
j,

View File

@ -16,7 +16,6 @@ type Props = {
modelKey?: string
live?: boolean
thumbTick?: number
autoTickMs?: number
blur?: boolean
className?: string
fit?: 'cover' | 'contain'
@ -44,7 +43,6 @@ export default function ModelPreview({
jobId,
live = false,
thumbTick,
autoTickMs = 10_000,
blur = false,
className,
fit = 'cover',
@ -53,8 +51,8 @@ export default function ModelPreview({
}: Props) {
const rootRef = useRef<HTMLDivElement | null>(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 (
<HoverPopover
@ -317,28 +298,30 @@ export default function ModelPreview({
{inView ? (
!imgError ? (
<img
src={previewSrc}
src={imgSrc || previewSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
onLoad={() => {
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)
}}
/>
) : (
<img
src={fallbackImgSrc}
loading="lazy"
decoding="async"
alt=""
className={['block w-full h-full', objectFitCls, 'object-center', blurCls].filter(Boolean).join(' ')}
/>
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />
)
) : (
<div className={['block w-full h-full bg-gray-100 dark:bg-white/5', blurCls].filter(Boolean).join(' ')} />

View File

@ -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<string, string>
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 ? (
<div className="absolute inset-0 bg-black">
<LiveVideo
src={liveHlsSrc}
src={livePreparedSrc}
muted={liveMuted}
volume={liveVolume}
onLoadingStart={() => {
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) ? (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/85 text-white">
<LoadingSpinner />
<div className="text-sm font-medium">
{livePreparing ? 'Live-Vorschau wird vorbereitet…' : 'Live-Stream wird geladen…'}
</div>
</div>
) : null}
<div className="absolute right-2 bottom-2 z-[60] flex items-center gap-2">
<button
type="button"