updated pending autostart
This commit is contained in:
parent
fbe9f306a3
commit
f0541b9fe6
253
backend/pending_autostart.go
Normal file
253
backend/pending_autostart.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(' ')} />
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user