// frontend\src\App.tsx import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' import Button from './components/ui/Button' import CookieModal from './components/ui/CookieModal' import Tabs, { type TabItem } from './components/ui/Tabs' import RecorderSettings from './components/ui/RecorderSettings' import FinishedDownloads from './components/ui/FinishedDownloads' import Player from './components/ui/Player' import type { RecordJob } from './types' import Downloads from './components/ui/Downloads' import ModelsTab from './components/ui/ModelsTab' import ProgressBar from './components/ui/ProgressBar' import ModelDetails from './components/ui/ModelDetails' import { SignalIcon, HeartIcon, HandThumbUpIcon } from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' const COOKIE_STORAGE_KEY = 'record_cookies' function normalizeCookies(obj: Record | null | undefined): Record { const input = obj ?? {} return Object.fromEntries( Object.entries(input) .map(([k, v]) => [k.trim().toLowerCase(), String(v ?? '').trim()] as const) .filter(([k, v]) => k.length > 0 && v.length > 0) ) } async function apiJSON(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } return res.json() as Promise } type RecorderSettings = { recordDir: string doneDir: string ffmpegPath?: string autoAddToDownloadList?: boolean autoStartAddedDownloads?: boolean useChaturbateApi?: boolean useMyFreeCamsWatcher?: boolean autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number blurPreviews?: boolean teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean } const DEFAULT_RECORDER_SETTINGS: RecorderSettings = { recordDir: 'records', doneDir: 'records/done', ffmpegPath: '', autoAddToDownloadList: false, autoStartAddedDownloads: false, useChaturbateApi: false, useMyFreeCamsWatcher: false, autoDeleteSmallDownloads: false, autoDeleteSmallDownloadsBelowMB: 50, blurPreviews: false, teaserPlayback: 'hover', teaserAudio: false, } type StoredModel = { id: string input: string host?: string modelKey: string watching: boolean favorite?: boolean liked?: boolean | null isUrl?: boolean path?: string } type PendingWatchedRoom = { id: string modelKey: string url: string currentShow: string // private/hidden/away/public/unknown imageUrl?: string } type ParsedModel = { input: string isUrl: boolean host?: string path?: string modelKey: string } type ChaturbateOnlineRoom = { username?: string current_show?: string chat_room_url?: string image_url?: string } type ChaturbateOnlineResponse = { enabled: boolean rooms: ChaturbateOnlineRoom[] } function normalizeHttpUrl(raw: string): string | null { let v = (raw ?? '').trim() if (!v) return null // häufige Copy/Paste-Randzeichen entfernen v = v.replace(/^[("'[{<]+/, '').replace(/[)"'\]}>.,;:]+$/, '') // ohne Scheme -> https:// if (!/^https?:\/\//i.test(v)) v = `https://${v}` try { const u = new URL(v) if (u.protocol !== 'http:' && u.protocol !== 'https:') return null return u.toString() } catch { return null } } function extractFirstUrl(text: string): string | null { const t = (text ?? '').trim() if (!t) return null for (const token of t.split(/\s+/g)) { const url = normalizeHttpUrl(token) if (url) return url } return null } type Provider = 'chaturbate' | 'mfc' function getProviderFromNormalizedUrl(normUrl: string): Provider | null { try { const host = new URL(normUrl).hostname.replace(/^www\./i, '').toLowerCase() if (host === 'chaturbate.com' || host.endsWith('.chaturbate.com')) return 'chaturbate' if (host === 'myfreecams.com' || host.endsWith('.myfreecams.com')) return 'mfc' return null } catch { return null } } const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' function replaceBasename(fullPath: string, newBase: string) { const norm = (fullPath || '').replaceAll('\\', '/') const parts = norm.split('/') parts[parts.length - 1] = newBase return parts.join('/') } function stripHotPrefix(name: string) { return name.startsWith('HOT ') ? name.slice(4) : name } // wie backend models.go const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/ function modelKeyFromFilename(fileOrPath: string): string | null { const file = stripHotPrefix(baseName(fileOrPath)) const base = file.replace(/\.[^.]+$/, '') // ext weg const m = base.match(reModel) if (m?.[1]?.trim()) return m[1].trim() const i = base.lastIndexOf('_') if (i > 0) return base.slice(0, i) return base ? base : null } export default function App() { const notify = useNotify() const DONE_PAGE_SIZE = 8 type DoneSortMode = | 'completed_desc' | 'completed_asc' | 'model_asc' | 'model_desc' | 'file_asc' | 'file_desc' | 'duration_desc' | 'duration_asc' | 'size_desc' | 'size_asc' const DONE_SORT_KEY = 'finishedDownloads_sort' const [doneSort, setDoneSort] = useState(() => { try { const v = window.localStorage.getItem(DONE_SORT_KEY) as DoneSortMode | null return v || 'completed_desc' } catch { return 'completed_desc' } }) useEffect(() => { try { window.localStorage.setItem(DONE_SORT_KEY, doneSort) } catch {} }, [doneSort]) const [playerModelKey, setPlayerModelKey] = useState(null) const [sourceUrl, setSourceUrl] = useState('') const [jobs, setJobs] = useState([]) const [doneJobs, setDoneJobs] = useState([]) const [donePage, setDonePage] = useState(1) const [doneCount, setDoneCount] = useState(0) const [modelsCount, setModelsCount] = useState(0) const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) const [nowMs, setNowMs] = useState(() => Date.now()) useEffect(() => { const t = window.setInterval(() => setNowMs(Date.now()), 1000) return () => window.clearInterval(t) }, []) const lower = (s: string) => (s || '').toLowerCase().trim() const formatAgoDE = (diffMs: number) => { const s = Math.max(0, Math.floor(diffMs / 1000)) if (s < 2) return 'gerade eben' if (s < 60) return `vor ${s} Sekunden` const m = Math.floor(s / 60) if (m === 1) return 'vor 1 Minute' if (m < 60) return `vor ${m} Minuten` const h = Math.floor(m / 60) if (h === 1) return 'vor 1 Stunde' return `vor ${h} Stunden` } const headerUpdatedText = useMemo(() => { const diff = nowMs - lastHeaderUpdateAtMs return `(zuletzt aktualisiert: ${formatAgoDE(diff)})` }, [nowMs, lastHeaderUpdateAtMs]) const [modelsByKey, setModelsByKey] = useState>({}) const buildModelsByKey = useCallback((list: StoredModel[]) => { const map: Record = {} for (const m of Array.isArray(list) ? list : []) { const k = (m?.modelKey || '').trim().toLowerCase() if (!k) continue const score = (x: StoredModel) => (x.favorite ? 4 : 0) + (x.liked === true ? 2 : 0) + (x.watching ? 1 : 0) const cur = map[k] if (!cur || score(m) >= score(cur)) map[k] = m } return map }, []) const refreshModelsByKey = useCallback(async () => { try { const list = await apiJSON('/api/models/list', { cache: 'no-store' as any }) setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : [])) setLastHeaderUpdateAtMs(Date.now()) } catch { // ignore } }, [buildModelsByKey]) const [playerModel, setPlayerModel] = useState(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) const [detailsModelKey, setDetailsModelKey] = useState(null) useEffect(() => { const onOpen = (ev: Event) => { const e = ev as CustomEvent<{ modelKey?: string }> const raw = (e.detail?.modelKey ?? '').trim() let k = raw.replace(/^https?:\/\//i, '') if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k if (k.includes(':')) k = k.split(':').pop() || k k = k.trim().toLowerCase() if (k) setDetailsModelKey(k) } window.addEventListener('open-model-details', onOpen as any) return () => window.removeEventListener('open-model-details', onOpen as any) }, []) const upsertModelCache = useCallback((m: StoredModel) => { const now = Date.now() const cur = modelsCacheRef.current if (!cur) { modelsCacheRef.current = { ts: now, list: [m] } return } cur.ts = now const idx = cur.list.findIndex((x) => x.id === m.id) if (idx >= 0) cur.list[idx] = m else cur.list.unshift(m) }, []) useEffect(() => { // initial laden void refreshModelsByKey() const onChanged = (ev: Event) => { const e = ev as CustomEvent const detail = e?.detail ?? {} const updated = detail?.model // ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models/list) if (updated && typeof updated === 'object') { const k = String(updated.modelKey ?? '').toLowerCase().trim() if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated })) try { upsertModelCache(updated) } catch {} setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev)) setLastHeaderUpdateAtMs(Date.now()) return } // ✅ 2) Delete-Event (optional): ebenfalls ohne Voll-Refresh if (detail?.removed) { const removedId = String(detail?.id ?? '').trim() const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim() if (removedKey) { setModelsByKey((prev) => { const { [removedKey]: _drop, ...rest } = prev return rest }) } if (removedId) { setPlayerModel((prev) => (prev?.id === removedId ? null : prev)) } setLastHeaderUpdateAtMs(Date.now()) return } // ✅ 3) Nur wenn kein Model/keine Delete-Info mitkommt: kompletter Refresh void refreshModelsByKey() } window.addEventListener('models-changed', onChanged as any) return () => window.removeEventListener('models-changed', onChanged as any) }, [refreshModelsByKey, upsertModelCache]) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [cookieModalOpen, setCookieModalOpen] = useState(false) const [cookies, setCookies] = useState>({}) const [cookiesLoaded, setCookiesLoaded] = useState(false) const [selectedTab, setSelectedTab] = useState('running') const [playerJob, setPlayerJob] = useState(null) const [playerExpanded, setPlayerExpanded] = useState(false) const [assetNonce, setAssetNonce] = useState(0) const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) const assetsBumpTimerRef = useRef(null) const bumpAssetsTwice = useCallback(() => { bumpAssets() if (assetsBumpTimerRef.current) window.clearTimeout(assetsBumpTimerRef.current) assetsBumpTimerRef.current = window.setTimeout(() => bumpAssets(), 3500) }, [bumpAssets]) const [recSettings, setRecSettings] = useState(DEFAULT_RECORDER_SETTINGS) const recSettingsRef = useRef(recSettings) useEffect(() => { recSettingsRef.current = recSettings }, [recSettings]) const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList) const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads) const [pendingWatchedRooms, setPendingWatchedRooms] = useState([]) const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState>({}) // "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet) const busyRef = useRef(false) const cookiesRef = useRef>({}) const jobsRef = useRef([]) useEffect(() => { busyRef.current = busy }, [busy]) useEffect(() => { cookiesRef.current = cookies }, [cookies]) useEffect(() => { jobsRef.current = jobs }, [jobs]) // pending start falls gerade busy const pendingStartUrlRef = useRef(null) const lastClipboardUrlRef = useRef('') // ✅ Online-Status für Models aus dem Model-Store const [onlineStoreKeysLower, setOnlineStoreKeysLower] = useState>({}) // ✅ Zentraler Snapshot: username(lower) -> room const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState>({}) const cbOnlineByKeyLowerRef = useRef>({}) useEffect(() => { cbOnlineByKeyLowerRef.current = cbOnlineByKeyLower }, [cbOnlineByKeyLower]) const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => { const h = String(m?.host ?? '').toLowerCase() const input = String(m?.input ?? '').toLowerCase() return h.includes('chaturbate') || input.includes('chaturbate.com') }, []) const chaturbateStoreKeysLower = useMemo(() => { const set = new Set() for (const m of Object.values(modelsByKey)) { if (!isChaturbateStoreModel(m)) continue const k = lower(String(m?.modelKey ?? '')) if (k) set.add(k) } return Array.from(set) }, [modelsByKey, isChaturbateStoreModel]) // ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird) const modelsByKeyRef = useRef(modelsByKey) useEffect(() => { modelsByKeyRef.current = modelsByKey }, [modelsByKey]) const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey) useEffect(() => { pendingAutoStartByKeyRef.current = pendingAutoStartByKey }, [pendingAutoStartByKey]) const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower) useEffect(() => { chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower }, [chaturbateStoreKeysLower]) const selectedTabRef = useRef(selectedTab) useEffect(() => { selectedTabRef.current = selectedTab }, [selectedTab]) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise => { const norm = normalizeHttpUrl(rawUrl) if (!norm) return false const silent = Boolean(opts?.silent) if (!silent) setError(null) const provider = getProviderFromNormalizedUrl(norm) if (!provider) { if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.') return false } const currentCookies = cookiesRef.current if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) { if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') return false } // Duplicate-running guard (normalisiert vergleichen) const alreadyRunning = jobsRef.current.some((j) => { if (j.status !== 'running') return false const jNorm = normalizeHttpUrl(String((j as any).sourceUrl || '')) return jNorm === norm }) if (alreadyRunning) return true // ✅ Chaturbate: parse modelKey + queue-logic über Snapshot if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { try { const parsed = await apiJSON('/api/models/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: norm }), }) const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() if (mkLower) { // 1) Wenn busy: IMMER queue (auch public, damit "public queued" sichtbar bleibt) if (busyRef.current) { setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm })) return true } // 2) Wenn Snapshot sagt: online aber NICHT public -> queue const room = cbOnlineByKeyLowerRef.current[mkLower] const show = String(room?.current_show ?? '') if (room && show && show !== 'public') { setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm })) return true } } } catch { // parse fail -> normal starten } } else { // Nicht-Chaturbate-API: wenn busy, wenigstens "pendingStart" setzen if (busyRef.current) { pendingStartUrlRef.current = norm return true } } if (busyRef.current) return false setBusy(true) busyRef.current = true try { const cookieString = Object.entries(currentCookies) .map(([k, v]) => `${k}=${v}`) .join('; ') const created = await apiJSON('/api/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: norm, cookie: cookieString }), }) setJobs((prev) => [created, ...prev]) jobsRef.current = [created, ...jobsRef.current] return true } catch (e: any) { if (!silent) setError(e?.message ?? String(e)) return false } finally { setBusy(false) busyRef.current = false } }, []) // ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility useEffect(() => { let cancelled = false const load = async () => { try { const s = await apiJSON('/api/settings', { cache: 'no-store' }) if (!cancelled && s) setRecSettings({ ...DEFAULT_RECORDER_SETTINGS, ...s }) } catch { // ignore } } const onUpdated = () => void load() const onFocus = () => void load() window.addEventListener('recorder-settings-updated', onUpdated as EventListener) window.addEventListener('hover', onFocus) document.addEventListener('visibilitychange', onFocus) load() return () => { cancelled = true window.removeEventListener('recorder-settings-updated', onUpdated as EventListener) window.removeEventListener('hover', onFocus) document.removeEventListener('visibilitychange', onFocus) } }, []) // ✅ Models-Count (leicht) useEffect(() => { let cancelled = false const load = async () => { try { const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' }) const c = Number(meta?.count ?? 0) if (!cancelled && Number.isFinite(c)) { setModelsCount(c) setLastHeaderUpdateAtMs(Date.now()) } } catch { // ignore } } load() const t = window.setInterval(load, document.hidden ? 60000 : 30000) return () => { cancelled = true window.clearInterval(t) } }, []) const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies]) const openPlayer = useCallback((job: RecordJob) => { modelsCacheRef.current = null setPlayerModel(null) setPlayerJob(job) setPlayerExpanded(false) }, []) const runningJobs = jobs.filter((j) => j.status === 'running') const onlineModelsCount = useMemo(() => { let c = 0 for (const m of Object.values(modelsByKey)) { const k = lower(String(m?.modelKey ?? '')) if (!k) continue if (onlineStoreKeysLower[k]) c++ } return c }, [modelsByKey, onlineStoreKeysLower]) const { onlineFavCount, onlineLikedCount } = useMemo(() => { let fav = 0 let liked = 0 for (const m of Object.values(modelsByKey)) { const k = lower(String(m?.modelKey ?? '')) if (!k) continue if (!onlineStoreKeysLower[k]) continue if (m?.favorite) fav++ if (m?.liked === true) liked++ } return { onlineFavCount: fav, onlineLikedCount: liked } }, [modelsByKey, onlineStoreKeysLower]) const tabs: TabItem[] = [ { id: 'running', label: 'Laufende Downloads', count: runningJobs.length }, { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount }, { id: 'models', label: 'Models', count: modelsCount }, { id: 'settings', label: 'Einstellungen' }, ] const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy]) // Cookies load/save useEffect(() => { let cancelled = false const load = async () => { try { const res = await apiJSON<{ cookies?: Record }>('/api/cookies', { cache: 'no-store' }) const fromBackend = normalizeCookies(res?.cookies) if (!cancelled) setCookies(fromBackend) if (Object.keys(fromBackend).length === 0) { const raw = localStorage.getItem(COOKIE_STORAGE_KEY) if (raw) { try { const obj = JSON.parse(raw) as Record const local = normalizeCookies(obj) if (Object.keys(local).length > 0) { if (!cancelled) setCookies(local) await apiJSON('/api/cookies', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cookies: local }), }) } } catch { // ignore } } } } catch { const raw = localStorage.getItem(COOKIE_STORAGE_KEY) if (raw) { try { const obj = JSON.parse(raw) as Record if (!cancelled) setCookies(normalizeCookies(obj)) } catch {} } } finally { if (!cancelled) setCookiesLoaded(true) } } load() return () => { cancelled = true } }, []) useEffect(() => { if (!cookiesLoaded) return localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) }, [cookies, cookiesLoaded]) // done meta polling (unverändert) useEffect(() => { let cancelled = false let t: number | undefined const loadDoneMeta = async () => { try { const res = await fetch('/api/record/done/meta', { cache: 'no-store' }) if (!res.ok) return const meta = (await res.json()) as { count?: number } if (!cancelled) { setDoneCount(meta.count ?? 0) setLastHeaderUpdateAtMs(Date.now()) } } catch { // ignore } finally { if (!cancelled) { const ms = document.hidden ? 60_000 : 30_000 t = window.setTimeout(loadDoneMeta, ms) } } } const onVis = () => { if (!document.hidden) void loadDoneMeta() } document.addEventListener('visibilitychange', onVis) void loadDoneMeta() return () => { cancelled = true if (t) window.clearTimeout(t) document.removeEventListener('visibilitychange', onVis) } }, []) useEffect(() => { const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE)) if (donePage > maxPage) setDonePage(maxPage) }, [doneCount, donePage]) // jobs SSE / polling (unverändert) useEffect(() => { let cancelled = false let es: EventSource | null = null let fallbackTimer: number | null = null let inFlight = false const applyList = (list: any) => { const arr = Array.isArray(list) ? (list as RecordJob[]) : [] if (cancelled) return const prev = jobsRef.current const prevById = new Map(prev.map((j) => [j.id, j.status])) const endedNow = arr.some((j) => { const ps = prevById.get(j.id) return ps && ps !== j.status && (j.status === 'finished' || j.status === 'stopped') }) setJobs(arr) jobsRef.current = arr setLastHeaderUpdateAtMs(Date.now()) if (endedNow) bumpAssetsTwice() setPlayerJob((prevJob) => { if (!prevJob) return prevJob const updated = arr.find((j) => j.id === prevJob.id) if (updated) return updated return prevJob.status === 'running' ? null : prevJob }) } const loadOnce = async () => { if (cancelled || inFlight) return inFlight = true try { const list = await apiJSON('/api/record/list') applyList(list) } catch { // ignore } finally { inFlight = false } } const startFallbackPolling = () => { if (fallbackTimer) return fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000) } void loadOnce() es = new EventSource('/api/record/stream') const onJobs = (ev: MessageEvent) => { try { applyList(JSON.parse(ev.data)) } catch {} } es.addEventListener('jobs', onJobs as any) es.onerror = () => startFallbackPolling() const onVis = () => { if (!document.hidden) void loadOnce() } document.addEventListener('visibilitychange', onVis) window.addEventListener('hover', onVis) return () => { cancelled = true if (fallbackTimer) window.clearInterval(fallbackTimer) document.removeEventListener('visibilitychange', onVis) window.removeEventListener('hover', onVis) es?.removeEventListener('jobs', onJobs as any) es?.close() es = null } }, [bumpAssetsTwice]) useEffect(() => { if (selectedTab !== 'finished') return let cancelled = false let inFlight = false const loadDone = async () => { if (cancelled || inFlight) return inFlight = true try { const list = await apiJSON( `/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`, { cache: 'no-store' as any } ) if (!cancelled) setDoneJobs(Array.isArray(list) ? list : []) } catch { if (!cancelled) setDoneJobs([]) } finally { inFlight = false } } loadDone() const baseMs = 20000 const tickMs = document.hidden ? 60000 : baseMs const t = window.setInterval(loadDone, tickMs) const onVis = () => { if (!document.hidden) void loadDone() } document.addEventListener('visibilitychange', onVis) return () => { cancelled = true window.clearInterval(t) document.removeEventListener('visibilitychange', onVis) } }, [selectedTab, donePage, doneSort]) const refreshDoneNow = useCallback( async (preferPage?: number) => { try { const meta = await apiJSON<{ count?: number }>('/api/record/done/meta', { cache: 'no-store' as any }) const countRaw = typeof meta?.count === 'number' ? meta.count : 0 const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 setDoneCount(count) const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE)) const wanted = typeof preferPage === 'number' ? preferPage : donePage const target = Math.min(Math.max(1, wanted), maxPage) if (target !== donePage) setDonePage(target) const list = await apiJSON( `/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`, { cache: 'no-store' as any } ) setDoneJobs(Array.isArray(list) ? list : []) } catch { // ignore } }, [donePage, doneSort] ) function isChaturbate(raw: string): boolean { const norm = normalizeHttpUrl(raw) if (!norm) return false try { return new URL(norm).hostname.includes('chaturbate.com') } catch { return false } } function getCookie(cookiesObj: Record, names: string[]): string | undefined { const lowerMap = Object.fromEntries(Object.entries(cookiesObj).map(([k, v]) => [k.trim().toLowerCase(), v])) for (const n of names) { const v = lowerMap[n.toLowerCase()] if (v) return v } return undefined } function hasRequiredChaturbateCookies(cookiesObj: Record): boolean { const cf = getCookie(cookiesObj, ['cf_clearance']) const sess = getCookie(cookiesObj, ['sessionid', 'session_id', 'sessionId']) return Boolean(cf && sess) } async function stopJob(id: string) { try { await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' }) } catch (e: any) { notify.error('Stop fehlgeschlagen', e?.message ?? String(e)) } } // ---- Player model sync (wie bei dir) ---- useEffect(() => { if (!playerJob) { setPlayerModel(null) setPlayerModelKey(null) return } const keyFromFile = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase() setPlayerModelKey(keyFromFile || null) const hit = keyFromFile ? modelsByKey[keyFromFile] : undefined setPlayerModel(hit ?? null) }, [playerJob, modelsByKey]) async function onStart() { return startUrl(sourceUrl) } const handleDeleteJob = useCallback(async (job: RecordJob) => { const file = baseName(job.output || '') if (!file) return window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })) try { await apiJSON(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' }) window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })) window.setTimeout(() => { setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) }, 320) } catch (e: any) { window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) notify.error('Löschen fehlgeschlagen', e?.message ?? String(e)) return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr } }, []) const handleKeepJob = useCallback( async (job: RecordJob) => { const file = baseName(job.output || '') if (!file) return window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })) try { await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }) window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })) window.setTimeout(() => { setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) }, 320) if (selectedTab !== 'finished') void refreshDoneNow() } catch (e: any) { window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })) notify.error('Keep fehlgeschlagen', e?.message ?? String(e)) return } }, [selectedTab, refreshDoneNow] ) const handleToggleHot = useCallback(async (job: RecordJob) => { const file = baseName(job.output || '') if (!file) return try { const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( `/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' } ) const newOutput = replaceBasename(job.output || '', res.newFile) setPlayerJob((prev) => (prev ? { ...prev, output: newOutput } : prev)) setDoneJobs((prev) => prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j)) ) setJobs((prev) => prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j)) ) } catch (e: any) { notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e)) return } }, [notify]) // --- flags patch (wie bei dir) --- async function patchModelFlags(patch: any): Promise { const res = await fetch('/api/models/flags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }) if (res.status === 204) return null if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } return res.json() } const removeModelCache = useCallback((id: string) => { const cur = modelsCacheRef.current if (!cur || !id) return cur.ts = Date.now() cur.list = cur.list.filter((x) => x.id !== id) }, []) // ✅ verhindert Doppel-Requests pro Model const flagsInFlightRef = useRef>({}) // ✅ handleToggleFavorite (komplett) const handleToggleFavorite = useCallback( async (job: RecordJob) => { const file = baseName(job.output || '') const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) // helper: host schnell aus job holen (für flags ohne id) const hostFromJob = (j: RecordJob): string => { try { const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '') const u = extractFirstUrl(urlFromJob) if (!u) return '' return new URL(u).hostname.replace(/^www\./i, '').toLowerCase() } catch { return '' } } // ✅ Fast-Path: modelKey aus Dateiname => instant optimistic + 1x flags call const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase() if (keyFromFile) { const guardKey = keyFromFile if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[keyFromFile] ?? ({ id: '', // unknown (ok) input: '', host: hostFromJob(job) || undefined, modelKey: keyFromFile, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextFav = !Boolean(prev.favorite) const optimistic: StoredModel = { ...prev, modelKey: prev.modelKey || keyFromFile, favorite: nextFav, liked: nextFav ? false : prev.liked, } setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(optimistic.id ? { id: optimistic.id } : {}), host: optimistic.host || hostFromJob(job) || '', modelKey: keyFromFile, favorite: nextFav, ...(nextFav ? { liked: false } : {}), }) // ✅ Model wurde gelöscht (204) if (!updated) { setModelsByKey((p) => { const { [keyFromFile]: _drop, ...rest } = p return rest }) if (prev.id) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } }) ) return } const k = lower(updated.modelKey || keyFromFile) if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { // rollback setModelsByKey((p) => ({ ...p, [keyFromFile]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } return } // ✅ Fallback: keinen modelKey aus Filename -> wie bisher resolve+ensure let m = sameAsPlayer ? playerModel : null if (!m) m = await resolveModelForJob(job, { ensure: true }) if (!m) return const guardKey = lower(m.modelKey || m.id || '') if (!guardKey) return if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = m const nextFav = !Boolean(prev.favorite) const optimistic: StoredModel = { ...prev, favorite: nextFav, liked: nextFav ? false : prev.liked, } const kPrev = lower(prev.modelKey || '') if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ id: prev.id, favorite: nextFav, ...(nextFav ? { liked: false } : {}), }) if (!updated) { setModelsByKey((p) => { const k = lower(prev.modelKey || '') if (!k) return p const { [k]: _drop, ...rest } = p return rest }) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } }) ) return } const k = lower(updated.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { const k = lower(prev.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey] ) // ✅ handleToggleLike (komplett) const handleToggleLike = useCallback( async (job: RecordJob) => { const file = baseName(job.output || '') const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) const hostFromJob = (j: RecordJob): string => { try { const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '') const u = extractFirstUrl(urlFromJob) if (!u) return '' return new URL(u).hostname.replace(/^www\./i, '').toLowerCase() } catch { return '' } } const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase() if (keyFromFile) { const guardKey = keyFromFile if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[keyFromFile] ?? ({ id: '', input: '', host: hostFromJob(job) || undefined, modelKey: keyFromFile, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextLiked = !(prev.liked === true) const optimistic: StoredModel = { ...prev, modelKey: prev.modelKey || keyFromFile, liked: nextLiked, favorite: nextLiked ? false : prev.favorite, } setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(optimistic.id ? { id: optimistic.id } : {}), host: optimistic.host || hostFromJob(job) || '', modelKey: keyFromFile, liked: nextLiked, ...(nextLiked ? { favorite: false } : {}), }) if (!updated) { setModelsByKey((p) => { const { [keyFromFile]: _drop, ...rest } = p return rest }) if (prev.id) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } }) ) return } const k = lower(updated.modelKey || keyFromFile) if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { setModelsByKey((p) => ({ ...p, [keyFromFile]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } return } // fallback let m = sameAsPlayer ? playerModel : null if (!m) m = await resolveModelForJob(job, { ensure: true }) if (!m) return const guardKey = lower(m.modelKey || m.id || '') if (!guardKey) return if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = m const nextLiked = !(prev.liked === true) const optimistic: StoredModel = { ...prev, liked: nextLiked, favorite: nextLiked ? false : prev.favorite, } const kPrev = lower(prev.modelKey || '') if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = nextLiked ? await patchModelFlags({ id: prev.id, liked: true, favorite: false }) : await patchModelFlags({ id: prev.id, liked: false }) if (!updated) { setModelsByKey((p) => { const k = lower(prev.modelKey || '') if (!k) return p const { [k]: _drop, ...rest } = p return rest }) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } }) ) return } const k = lower(updated.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { const k = lower(prev.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey] ) // ✅ handleToggleWatch (komplett) const handleToggleWatch = useCallback( async (job: RecordJob) => { const file = baseName(job.output || '') const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) const hostFromJob = (j: RecordJob): string => { try { const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '') const u = extractFirstUrl(urlFromJob) if (!u) return '' return new URL(u).hostname.replace(/^www\./i, '').toLowerCase() } catch { return '' } } const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase() if (keyFromFile) { const guardKey = keyFromFile if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[keyFromFile] ?? ({ id: '', input: '', host: hostFromJob(job) || undefined, modelKey: keyFromFile, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextWatching = !Boolean(prev.watching) const optimistic: StoredModel = { ...prev, modelKey: prev.modelKey || keyFromFile, watching: nextWatching, } setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(optimistic.id ? { id: optimistic.id } : {}), host: optimistic.host || hostFromJob(job) || '', modelKey: keyFromFile, watched: nextWatching, // ✅ API key watched => watching in DB }) if (!updated) { setModelsByKey((p) => { const { [keyFromFile]: _drop, ...rest } = p return rest }) if (prev.id) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: keyFromFile } }) ) return } const k = lower(updated.modelKey || keyFromFile) if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { setModelsByKey((p) => ({ ...p, [keyFromFile]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } return } // fallback let m = sameAsPlayer ? playerModel : null if (!m) m = await resolveModelForJob(job, { ensure: true }) if (!m) return const guardKey = lower(m.modelKey || m.id || '') if (!guardKey) return if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = m const nextWatching = !Boolean(prev.watching) const optimistic: StoredModel = { ...prev, watching: nextWatching, } const kPrev = lower(prev.modelKey || '') if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ id: prev.id, watched: nextWatching }) if (!updated) { setModelsByKey((p) => { const k = lower(prev.modelKey || '') if (!k) return p const { [k]: _drop, ...rest } = p return rest }) removeModelCache(prev.id) if (sameAsPlayer) setPlayerModel(null) window.dispatchEvent( new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } }) ) return } const k = lower(updated.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) upsertModelCache(updated) if (sameAsPlayer) setPlayerModel(updated) window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) } catch (e: any) { const k = lower(prev.modelKey || '') if (k) setModelsByKey((p) => ({ ...p, [k]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey] ) async function resolveModelForJob( job: RecordJob, opts?: { ensure?: boolean } ): Promise { const wantEnsure = Boolean(opts?.ensure) const upsertCache = (m: StoredModel) => { const now = Date.now() const cur = modelsCacheRef.current if (!cur) { modelsCacheRef.current = { ts: now, list: [m] } return } cur.ts = now const idx = cur.list.findIndex((x) => x.id === m.id) if (idx >= 0) cur.list[idx] = m else cur.list.unshift(m) } const resolveByKey = async (key: string): Promise => { if (!key) return null const needle = key.trim().toLowerCase() if (!needle) return null // ✅ 0) Sofort aus App-State (schnellster Pfad) const stateHit = modelsByKey[needle] if (stateHit) { upsertCache(stateHit) return stateHit } // ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models/list) if (wantEnsure) { let host: string | undefined // versuche Host aus sourceUrl zu nehmen (falls vorhanden) try { const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string const url = extractFirstUrl(urlFromJob) if (url) host = new URL(url).hostname.replace(/^www\./i, '').toLowerCase() } catch {} const ensured = await apiJSON('/api/models/ensure', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelKey: key, ...(host ? { host } : {}) }), }) upsertModelCache(ensured) return ensured } // ✅ 2) Cache refreshen / initial füllen (nur wenn KEIN ensure) const now = Date.now() const cached = modelsCacheRef.current if (!cached || now - cached.ts > 30_000) { // erst aus State seeden (falls vorhanden) const seeded = Object.values(modelsByKey) if (seeded.length) { modelsCacheRef.current = { ts: now, list: seeded } } else { const list = await apiJSON('/api/models/list', { cache: 'no-store' as any }) modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] } } } const list = modelsCacheRef.current?.list ?? [] // ✅ 3) im Cache suchen const hit = list.find((m) => (m.modelKey || '').trim().toLowerCase() === needle) if (hit) return hit return null } const keyFromFile = modelKeyFromFilename(job.output || '') // ✅ Wichtig: IMMER zuerst Dateiname (auch bei running) // -> spart /parse + /upsert und macht Toggle instant if (keyFromFile) { return resolveByKey(keyFromFile) } const isRunning = job.status === 'running' // ✅ Nur wenn wir wirklich KEINEN Key aus dem Output haben: const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string const url = extractFirstUrl(urlFromJob) if (isRunning && url) { const parsed = await apiJSON('/api/models/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: url }), }) const saved = await apiJSON('/api/models/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(parsed), }) upsertModelCache(saved) return saved } // Fallback: wenn gar nix geht return null } // Clipboard auto add/start (wie bei dir) useEffect(() => { if (!autoAddEnabled && !autoStartEnabled) return if (!navigator.clipboard?.readText) return let cancelled = false let inFlight = false let timer: number | null = null const checkClipboard = async () => { if (cancelled || inFlight) return inFlight = true try { const text = await navigator.clipboard.readText() const url = extractFirstUrl(text) if (!url) return if (!getProviderFromNormalizedUrl(url)) return if (url === lastClipboardUrlRef.current) return lastClipboardUrlRef.current = url if (autoAddEnabled) setSourceUrl(url) if (autoStartEnabled) { if (busyRef.current) { pendingStartUrlRef.current = url } else { pendingStartUrlRef.current = null await startUrl(url) } } } catch { // ignore } finally { inFlight = false } } const schedule = (ms: number) => { if (cancelled) return timer = window.setTimeout(async () => { await checkClipboard() schedule(document.hidden ? 5000 : 1500) }, ms) } const kick = () => void checkClipboard() window.addEventListener('hover', kick) document.addEventListener('visibilitychange', kick) schedule(0) return () => { cancelled = true if (timer) window.clearTimeout(timer) window.removeEventListener('hover', kick) document.removeEventListener('visibilitychange', kick) } }, [autoAddEnabled, autoStartEnabled, startUrl]) useEffect(() => { if (busy) return if (!autoStartEnabled) return const pending = pendingStartUrlRef.current if (!pending) return pendingStartUrlRef.current = null void startUrl(pending) }, [busy, autoStartEnabled, startUrl]) useEffect(() => { const stop = startChaturbateOnlinePolling({ getModels: () => { if (!recSettingsRef.current.useChaturbateApi) return [] const modelsMap = modelsByKeyRef.current const pendingMap = pendingAutoStartByKeyRef.current const watchedKeysLower = Object.values(modelsMap) .filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate')) .map((m) => String(m?.modelKey ?? '').trim().toLowerCase()) .filter(Boolean) const queuedKeysLower = Object.keys(pendingMap || {}) .map((k) => String(k || '').trim().toLowerCase()) .filter(Boolean) // ✅ NUR watched + queued pollen (Store kann riesig sein -> lag) // Wenn du Store-Online später willst: extra, seltener Poll (z.B. 60s) separat lösen. return Array.from(new Set([...watchedKeysLower, ...queuedKeysLower])) }, getShow: () => ['public', 'private', 'hidden', 'away'], intervalMs: 12000, onData: (data: ChaturbateOnlineResponse) => { void (async () => { if (!data?.enabled) { setCbOnlineByKeyLower({}) cbOnlineByKeyLowerRef.current = {} setOnlineStoreKeysLower({}) setPendingWatchedRooms([]) setLastHeaderUpdateAtMs(Date.now()) return } const nextSnap: Record = {} for (const r of Array.isArray(data.rooms) ? data.rooms : []) { const u = String(r?.username ?? '').trim().toLowerCase() if (u) nextSnap[u] = r } setCbOnlineByKeyLower(nextSnap) cbOnlineByKeyLowerRef.current = nextSnap // Online-Keys für Store const storeKeys = chaturbateStoreKeysLowerRef.current const nextOnlineStore: Record = {} for (const k of storeKeys || []) { const kl = String(k || '').trim().toLowerCase() if (kl && nextSnap[kl]) nextOnlineStore[kl] = true } setOnlineStoreKeysLower(nextOnlineStore) // Pending Watched Rooms (nur im running Tab) if (!recSettingsRef.current.useChaturbateApi) { setPendingWatchedRooms([]) } else if (selectedTabRef.current !== 'running') { // optional: nicht leeren } else { const modelsMap = modelsByKeyRef.current const pendingMap = pendingAutoStartByKeyRef.current const watchedKeysLower = Array.from( new Set( Object.values(modelsMap) .filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate')) .map((m) => String(m?.modelKey ?? '').trim().toLowerCase()) .filter(Boolean) ) ) const queuedKeysLower = Object.keys(pendingMap || {}) .map((k) => String(k || '').trim().toLowerCase()) .filter(Boolean) const queuedSetLower = new Set(queuedKeysLower) const keysToCheckLower = Array.from(new Set([...watchedKeysLower, ...queuedKeysLower])) if (keysToCheckLower.length === 0) { setPendingWatchedRooms([]) } else { const nextPending: PendingWatchedRoom[] = [] for (const keyLower of keysToCheckLower) { const room = nextSnap[keyLower] if (!room) continue const username = String(room?.username ?? '').trim() const currentShow = String(room?.current_show ?? 'unknown') if (currentShow === 'public' && !queuedSetLower.has(keyLower)) continue const canonicalUrl = `https://chaturbate.com/${(username || keyLower).trim()}/` nextPending.push({ id: keyLower, modelKey: username || keyLower, url: canonicalUrl, currentShow, imageUrl: String((room as any)?.image_url ?? ''), }) } nextPending.sort((a, b) => a.modelKey.localeCompare(b.modelKey, undefined, { sensitivity: 'base' })) setPendingWatchedRooms(nextPending) } } // queued auto-start if (!recSettingsRef.current.useChaturbateApi) return if (busyRef.current) return const pendingMap = pendingAutoStartByKeyRef.current const keys = Object.keys(pendingMap || {}) .map((k) => String(k || '').toLowerCase()) .filter(Boolean) for (const kLower of keys) { const room = nextSnap[kLower] if (!room) continue if (String(room.current_show ?? '') !== 'public') continue const url = pendingMap[kLower] if (!url) continue const ok = await startUrl(url, { silent: true }) if (ok) { // ✅ State + Ref gleichzeitig “synchron” löschen setPendingAutoStartByKey((prev) => { const copy = { ...(prev || {}) } delete copy[kLower] pendingAutoStartByKeyRef.current = copy return copy }) } } setLastHeaderUpdateAtMs(Date.now()) })() }, }) return () => stop() }, []) return (