// 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, EyeIcon, ArrowDownTrayIcon, ArchiveBoxIcon, UserGroupIcon, Squares2X2Icon, Cog6ToothIcon, } from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' //import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' import CategoriesTab from './components/ui/CategoriesTab' import LoginPage from './components/ui/LoginPage' import VideoSplitModal from './components/ui/VideoSplitModal' import TextInput from './components/ui/TextInput' import LastUpdatedText from './components/ui/LastUpdatedText' 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 RecorderSettingsState = { recordDir: string doneDir: string ffmpegPath?: string autoAddToDownloadList?: boolean autoStartAddedDownloads?: boolean enableConcurrentDownloadsLimit?: boolean maxConcurrentDownloads?: number useChaturbateApi?: boolean useMyFreeCamsWatcher?: boolean autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number blurPreviews?: boolean teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean lowDiskPauseBelowGB?: number enableNotifications?: boolean } type JobEvent = { type?: 'job_upsert' | 'job_remove' model?: string jobId?: string status?: string phase?: string progress?: number sourceUrl?: string output?: string startedAt?: string startedAtMs?: number endedAt?: string endedAtMs?: number sizeBytes?: number durationSeconds?: number previewState?: string roomStatus?: string isOnline?: boolean modelImageUrl?: string modelChatRoomUrl?: string ts?: number error?: string postWorkKey?: string postWork?: { state?: string position?: number waiting?: number running?: number maxParallel?: number } // ✅ neu für finished_postwork file?: string assetId?: string queue?: 'postwork' | 'enrich' state?: 'queued' | 'running' | 'done' | 'error' | 'missing' label?: string position?: number waiting?: number running?: number maxParallel?: number } type TaskStateEvent = { type?: 'task_state' generateAssetsRunning?: boolean regenerateAssetsRunning?: boolean cleanupRunning?: boolean anyRunning?: boolean ts?: number } const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', ffmpegPath: '', autoAddToDownloadList: false, autoStartAddedDownloads: false, enableConcurrentDownloadsLimit: false, maxConcurrentDownloads: 20, useChaturbateApi: false, useMyFreeCamsWatcher: false, autoDeleteSmallDownloads: false, autoDeleteSmallDownloadsBelowMB: 200, blurPreviews: false, teaserPlayback: 'hover', teaserAudio: false, lowDiskPauseBelowGB: 5, enableNotifications: true, } type StoredModel = { id: string input: string host?: string modelKey: string watching: boolean favorite?: boolean liked?: boolean | null isUrl?: boolean path?: string roomStatus?: string isOnline?: boolean chatRoomUrl?: string imageUrl?: string lastOnlineAt?: string lastOfflineAt?: string lastRoomSyncAt?: 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 ChaturbateOnlineRoomItem = { username?: string current_show?: string chat_room_url?: string image_url?: string } type ChaturbateOnlineResponse = { enabled?: boolean fetchedAt?: string count?: number total?: number lastError?: string rooms?: ChaturbateOnlineRoomItem[] } type AutostartState = { paused?: boolean pausedByUser?: boolean pausedByDisk?: boolean } function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { const s = String(v ?? '').trim().toLowerCase() switch (s) { case 'public': return 'public' case 'private': return 'private' case 'hidden': return 'hidden' case 'away': return 'away' case 'offline': return 'offline' default: return 'unknown' } } function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { return normalizePendingShow(v) } function roomStatusLabelDE(v: unknown): string { switch (normalizeRoomStatusText(v)) { case 'public': return 'live' case 'private': return 'privat' case 'hidden': return 'hidden' case 'away': return 'away' case 'offline': return 'offline' default: return 'unbekannt' } } 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 } } function chaturbateUserFromUrl(normUrl: string): string { try { const u = new URL(normUrl) const host = u.hostname.replace(/^www\./i, '').toLowerCase() if (host !== 'chaturbate.com' && !host.endsWith('.chaturbate.com')) return '' // https://chaturbate.com//... const parts = u.pathname.split('/').filter(Boolean) return parts[0] ? decodeURIComponent(parts[0]).trim() : '' } catch { return '' } } /** * Macht aus "beliebigen" Provider-URLs eine EINDEUTIGE Standardform. * -> wichtig für dedupe (Queue, alreadyRunning), Clipboard, Pending-Maps. */ function canonicalizeProviderUrl(normUrl: string): string { const provider = getProviderFromNormalizedUrl(normUrl) if (!provider) return normUrl if (provider === 'chaturbate') { const name = chaturbateUserFromUrl(normUrl) return name ? `https://chaturbate.com/${encodeURIComponent(name)}/` : normUrl } // provider === 'mfc' const name = mfcUserFromUrl(normUrl) // Standardisiere auf EIN Format (hier: #) return name ? `https://www.myfreecams.com/#${encodeURIComponent(name)}` : normUrl } /** Gibt den "ModelKey" aus einer URL zurück (lowercased) – für beide Provider */ function providerKeyLowerFromUrl(normUrl: string): string { const provider = getProviderFromNormalizedUrl(normUrl) if (!provider) return '' const raw = provider === 'chaturbate' ? chaturbateUserFromUrl(normUrl) : mfcUserFromUrl(normUrl) return (raw || '').trim().toLowerCase() } function mfcUserFromUrl(normUrl: string): string { try { const u = new URL(normUrl) const host = u.hostname.replace(/^www\./i, '').toLowerCase() // nur MFC if (host !== 'myfreecams.com' && !host.endsWith('.myfreecams.com')) return '' // typische MFC Profile-URLs: // https://www.myfreecams.com/# // https://www.myfreecams.com/#/models/ // https://www.myfreecams.com/ (seltener) const hash = (u.hash || '').replace(/^#\/?/, '') // "#/models/foo" -> "models/foo" if (hash) { const parts = hash.split('/').filter(Boolean) const last = parts[parts.length - 1] || '' if (last) return decodeURIComponent(last).trim() } const parts = u.pathname.split('/').filter(Boolean) const last = parts[parts.length - 1] || '' return last ? decodeURIComponent(last).trim() : '' } catch { return '' } } const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || '' function videoSrcFromJob(job: RecordJob | null): string { if (!job) return '' const file = baseName(job.output || '') if (!file) return '' return `/api/record/video?file=${encodeURIComponent(file)}` } 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 chaturbateModelKeyFromJob(job: RecordJob): string { const raw = String((job as any)?.sourceUrl ?? '').trim() const norm0 = normalizeHttpUrl(raw) const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' const keyFromUrl = providerKeyLowerFromUrl(norm) if (keyFromUrl) return keyFromUrl const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() return keyFromFile } 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 } function modelEventKeyFromJob(job: RecordJob): string { const raw = String((job as any)?.sourceUrl ?? '').trim() const norm0 = normalizeHttpUrl(raw) const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' const keyFromUrl = providerKeyLowerFromUrl(norm) if (keyFromUrl) return keyFromUrl const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() return keyFromFile } function modelEventKeyFromDoneJob(job: RecordJob): string { const { modelKey } = modelKeyAndHostFromJob(job) return String(modelKey || '').trim().toLowerCase() } function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } { const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase() let host = '' try { const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '') const u0 = extractFirstUrl(raw) if (u0) { const u = new URL(u0) host = u.hostname.replace(/^www\./i, '').toLowerCase() } } catch { // ignore } if (keyFromFile) { return { modelKey: keyFromFile, host } } const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '') const u0 = extractFirstUrl(raw) if (u0) { const norm0 = normalizeHttpUrl(u0) const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' const keyFromUrl = providerKeyLowerFromUrl(norm) if (keyFromUrl) { return { modelKey: keyFromUrl, host } } } return { modelKey: '', host } } const isTerminalJobStatusForCount = (status?: unknown) => { const s = String(status ?? '').trim().toLowerCase() return ( s === 'stopped' || s === 'finished' || s === 'failed' || s === 'done' || s === 'completed' || s === 'canceled' || s === 'cancelled' ) } const isPostworkJobForCount = (job: RecordJob): boolean => { const anyJ = job as any const phase = String(anyJ.phase ?? '').trim() const pw = anyJ.postWork const pwKey = String(anyJ.postWorkKey ?? '').trim() if (pwKey) return true if (pw && (pw.state === 'queued' || pw.state === 'running')) return true if (job.endedAt && phase) return true if (phase.toLowerCase() === 'postwork') return true return false } function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] { const incomingId = String((incoming as any)?.id ?? '').trim() if (!incomingId) return list const byId = new Map() for (const j of list) { const id = String((j as any)?.id ?? '').trim() if (!id) continue byId.set(id, j) } byId.set(incomingId, { ...(byId.get(incomingId) ?? ({} as RecordJob)), ...incoming, }) return Array.from(byId.values()).sort((a, b) => { const aMs = Number((a as any)?.startedAtMs ?? 0) || (a?.startedAt ? Date.parse(a.startedAt) || 0 : 0) const bMs = Number((b as any)?.startedAtMs ?? 0) || (b?.startedAt ? Date.parse(b.startedAt) || 0 : 0) return bMs - aMs }) } export default function App() { const [authChecked, setAuthChecked] = useState(false) const [authed, setAuthed] = useState(false) const sourceUrlInputRef = useRef(null) const notify = useNotify() const notifyRef = useRef(notify) const lastKnownRoomStatusByKeyRef = useRef>({}) const roomStatusBaselineReadyRef = useRef(false) // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen) const cookieProblemLastAtRef = useRef(0) const donePrefetchRef = useRef(null) const donePrefetchInFlightRef = useRef(false) const doneCountInFlightRef = useRef(false) const doneCountLastAtRef = useRef(0) const [playerModel, setPlayerModel] = useState(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) const [detailsModelKey, setDetailsModelKey] = useState(null) 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 [settingsTaskRunning, setSettingsTaskRunning] = useState(false) const [playerJob, setPlayerJob] = useState(null) const [playerExpanded, setPlayerExpanded] = useState(false) const [playerStartAtSec, setPlayerStartAtSec] = useState(null) const [splitJob, setSplitJob] = useState(null) const [splitModalOpen, setSplitModalOpen] = useState(false) const [splitModalKey, setSplitModalKey] = useState(0) const [assetNonce, setAssetNonce] = useState(0) const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) const [recSettings, setRecSettings] = useState(DEFAULT_RECORDER_SETTINGS) 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([]) // ✅ "Job gestartet" Toast: dedupe (auch gegen SSE/polling) + initial-load suppression const startedToastByJobIdRef = useRef>({}) const jobsInitDoneRef = useRef(false) const lower = (s: string) => (s || '').toLowerCase().trim() const eventSourceRef = useRef(null) const modelEventNamesRef = useRef>(new Set()) const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) const lastSeenAtByJobIdRef = useRef>({}) const MISSING_JOB_GRACE_MS = 20_000 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 [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) const [cbOnlineFetchedAt, setCbOnlineFetchedAt] = useState('') const [modelsByKey, setModelsByKey] = useState>({}) const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2' const [includeKeep, setIncludeKeep] = useState(() => { try { const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY) return raw === '1' || raw === 'true' || raw === 'yes' } catch { return false } }) const checkAuth = useCallback(async () => { try { const res = await apiJSON<{ authenticated?: boolean }>('/api/auth/me', { cache: 'no-store' as any }) setAuthed(Boolean(res?.authenticated)) } catch { setAuthed(false) } finally { setAuthChecked(true) } }, []) const logout = useCallback(async () => { try { // Backend löscht Session + Cookie (204) await fetch('/api/auth/logout', { method: 'POST', cache: 'no-store' as any }) } catch { // ignore } finally { // UI/State reset setAuthed(false) setAuthChecked(true) setError(null) setBusy(false) // optional: UI zurücksetzen, damit nach erneutem Login alles "clean" ist setSelectedTab('running') setPlayerJob(null) setPlayerExpanded(false) setDetailsModelKey(null) // optional: Listen leeren (verhindert kurz sichtbare "alte" Daten beim Login-Screen) setJobs([]) setDoneJobs([]) setDoneCount(0) setDonePage(1) setSplitJob(null) setSplitModalOpen(false) setModelsByKey({}) setModelsCount(0) startedToastByJobIdRef.current = {} jobsInitDoneRef.current = false setPendingWatchedRooms([]) setPendingAutoStartByKey({}) // optional: URL-Feld leeren setSourceUrl('') } }, []) useEffect(() => { void checkAuth() }, [checkAuth]) const isCookieGateError = (msg: string) => { const m = (msg || '').toLowerCase() return ( m.includes('altersverifikationsseite erhalten') || m.includes('verify your age') || m.includes('schutzseite von cloudflare erhalten') || m.includes('just a moment') || m.includes('kein room-html') ) } const showMissingCookiesMessage = (opts?: { silent?: boolean }) => { const silent = Boolean(opts?.silent) const title = 'Cookies fehlen oder sind abgelaufen' const body = 'Der Recorder hat statt des Room-HTML eine Schutz-/Altersverifikationsseite erhalten. ' + 'Bitte Cookies aktualisieren (bei Chaturbate z.B. cf_clearance + sessionId) und erneut starten.' // Wenn Nutzer aktiv klickt: oben als Error-Box zeigen + Cookie-Modal anbieten if (!silent) { setError(`⚠️ ${title}. ${body}`) // optional aber hilfreich: Modal direkt öffnen setCookieModalOpen(true) return } // Bei silent (Auto-Start / Queue): nur selten Toast const now = Date.now() if (now - cookieProblemLastAtRef.current > 15_000) { cookieProblemLastAtRef.current = now notifyRef.current?.error(title, body) } } useEffect(() => { notifyRef.current = notify }, [notify]) // ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen const [showPerfMon, setShowPerfMon] = useState(false) useEffect(() => { const w = window as any const id = typeof w.requestIdleCallback === 'function' ? w.requestIdleCallback(() => setShowPerfMon(true), { timeout: 1500 }) : window.setTimeout(() => setShowPerfMon(true), 800) return () => { if (typeof w.cancelIdleCallback === 'function') w.cancelIdleCallback(id) else window.clearTimeout(id) } }, []) const BASE_DONE_PAGE_SIZE = 6 const [donePageSize, setDonePageSize] = useState(BASE_DONE_PAGE_SIZE) type DoneSortMode = | 'completed_desc' | 'completed_asc' | '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' } }) type DonePrefetch = { key: string items: RecordJob[] ts: number } const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}` const getDoneTotalPages = (count: number, pageSize = donePageSize) => { const safeCount = Number.isFinite(count) && count > 0 ? count : 0 return Math.max(1, Math.ceil(safeCount / pageSize)) } const prefetchDonePage = useCallback(async (pageToFetch: number) => { if (pageToFetch < 1) return if (donePrefetchInFlightRef.current) return const key = makePrefetchKey(pageToFetch, doneSort) const cur = donePrefetchRef.current if (cur?.key === key && Date.now() - cur.ts < 15_000) { // frisch genug return } donePrefetchInFlightRef.current = true try { const res = await fetch( `/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${ includeKeep ? '&includeKeep=1' : '' }`, { cache: 'no-store' as any } ) if (!res.ok) return const data = await res.json().catch(() => null) const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : Array.isArray(data) ? (data as RecordJob[]) : [] donePrefetchRef.current = { key, items, ts: Date.now() } } finally { donePrefetchInFlightRef.current = false } }, [doneSort, includeKeep, donePageSize]) const loadDoneCount = useCallback(async () => { const now = Date.now() // ✅ harte Dedupe-Schranke gegen Bursts if (doneCountInFlightRef.current) return if (now - doneCountLastAtRef.current < 500) return doneCountInFlightRef.current = true doneCountLastAtRef.current = now try { const res = await fetch( `/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`, { cache: 'no-store' as any } ) if (!res.ok) return const data = await res.json().catch(() => null) const countRaw = Number(data?.count ?? 0) const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 setDoneCount(count) } catch { // ignore } finally { doneCountInFlightRef.current = false } }, [includeKeep]) const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => { try { const key = makePrefetchKey(pageToLoad, sortToLoad) const prefetched = donePrefetchRef.current if (prefetched?.key === key && Array.isArray(prefetched.items)) { setDoneJobs(prefetched.items) donePrefetchRef.current = null return } const res = await fetch( `/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}&withCount=1${ includeKeep ? '&includeKeep=1' : '' }`, { cache: 'no-store' as any } ) if (!res.ok) return const data = await res.json().catch(() => null) const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] setDoneJobs(items) const countRaw = Number(data?.count ?? doneCount) const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 if (count !== doneCount) { setDoneCount(count) } const totalPages = getDoneTotalPages(count, donePageSize) if (pageToLoad < totalPages) { void prefetchDonePage(pageToLoad + 1) } } catch { // ignore } }, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount]) useEffect(() => { try { window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0') } catch { // ignore } }, [includeKeep]) const loadJobs = useCallback(async () => { try { const res = await fetch('/api/record/list', { cache: 'no-store' as any }) if (!res.ok) return const data = await res.json().catch(() => null) const serverItems = Array.isArray(data) ? (data as RecordJob[]) : Array.isArray(data?.items) ? (data.items as RecordJob[]) : [] const now = Date.now() for (const j of serverItems) { if (j?.id) { lastSeenAtByJobIdRef.current[j.id] = now } } setJobs((prev) => { const byId = new Map() // 1) Server gewinnt immer for (const j of serverItems) { if (!j?.id) continue byId.set(j.id, j) } // 2) Lokale aktive Jobs kurz weiter behalten, wenn sie serverseitig // vorübergehend fehlen for (const j of prev) { if (!j?.id) continue if (byId.has(j.id)) continue const status = String(j.status ?? '').trim().toLowerCase() const phase = String((j as any)?.phase ?? '').trim().toLowerCase() const isTerminal = status === 'stopped' || status === 'finished' || status === 'failed' || status === 'done' || status === 'completed' || status === 'canceled' || status === 'cancelled' const isActive = !j.endedAt && !isTerminal && (status === 'running' || phase === 'recording' || phase === '') const lastSeen = lastSeenAtByJobIdRef.current[j.id] ?? 0 const withinGrace = now - lastSeen < MISSING_JOB_GRACE_MS if (isActive || withinGrace) { byId.set(j.id, j) } } const next = Array.from(byId.values()).sort((a, b) => { const ta = new Date(a.startedAt || 0).getTime() const tb = new Date(b.startedAt || 0).getTime() return tb - ta }) jobsRef.current = next return next }) } catch { // ignore } }, []) useEffect(() => { try { window.localStorage.setItem(DONE_SORT_KEY, doneSort) } catch {} }, [doneSort]) useEffect(() => { if (!authed) return if (selectedTab !== 'finished') return void loadDonePage(donePage, doneSort) }, [authed, selectedTab, donePage, doneSort, loadDonePage]) 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 applyJobDelta = useCallback((msg: JobEvent) => { const jobId = String(msg?.jobId ?? '').trim() if (!jobId) return const now = Date.now() const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => { return { ...(prevJob ?? ({} as RecordJob)), id: jobId, status: (msg.status as any) ?? (prevJob?.status ?? 'running'), sourceUrl: msg.sourceUrl ?? ((prevJob as any)?.sourceUrl ?? ''), output: msg.output ?? (prevJob?.output ?? ''), startedAt: msg.startedAt ?? (prevJob?.startedAt ?? ''), endedAt: msg.endedAt ?? (prevJob?.endedAt ?? undefined), phase: msg.phase ?? (prevJob as any)?.phase, progress: msg.progress ?? (prevJob as any)?.progress, startedAtMs: msg.startedAtMs ?? (prevJob as any)?.startedAtMs, endedAtMs: msg.endedAtMs ?? (prevJob as any)?.endedAtMs, sizeBytes: msg.sizeBytes ?? (prevJob as any)?.sizeBytes, durationSeconds: msg.durationSeconds ?? (prevJob as any)?.durationSeconds, previewState: msg.previewState ?? (prevJob as any)?.previewState, roomStatus: msg.roomStatus ?? (prevJob as any)?.roomStatus, isOnline: msg.isOnline ?? (prevJob as any)?.isOnline, modelImageUrl: msg.modelImageUrl ?? (prevJob as any)?.modelImageUrl, modelChatRoomUrl: msg.modelChatRoomUrl ?? (prevJob as any)?.modelChatRoomUrl, postWorkKey: msg.postWorkKey ?? (prevJob as any)?.postWorkKey, postWork: msg.postWork ? { ...((prevJob as any)?.postWork ?? {}), ...msg.postWork, } : (prevJob as any)?.postWork, } as any } if (msg.type === 'job_remove') { delete lastSeenAtByJobIdRef.current[jobId] setJobs((prev) => { const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId) jobsRef.current = next return next }) setPlayerJob((prev) => prev && String((prev as any).id ?? '').trim() === jobId ? null : prev ) return } if (msg.type !== 'job_upsert') return lastSeenAtByJobIdRef.current[jobId] = now setJobs((prev) => { const sameId = prev.filter((j) => String((j as any).id ?? '').trim() === jobId) const prevJob = sameId.length > 0 ? sameId[0] : null const merged = mergeJob(prevJob) const next = [ merged, ...prev.filter((j) => String((j as any).id ?? '').trim() !== jobId), ].sort((a, b) => { const aMs = Number((a as any)?.startedAtMs ?? 0) || (a?.startedAt ? Date.parse(a.startedAt) || 0 : 0) const bMs = Number((b as any)?.startedAtMs ?? 0) || (b?.startedAt ? Date.parse(b.startedAt) || 0 : 0) return bMs - aMs }) jobsRef.current = next return next }) setPlayerJob((prev) => { if (!prev) return prev if (String((prev as any).id ?? '').trim() !== jobId) return prev return mergeJob(prev) }) }, []) const ensureModelEventListener = useCallback((name: string) => { const es = eventSourceRef.current const handler = onModelJobEventRef.current const n = String(name ?? '').trim().toLowerCase() if (!es || !handler || !n) return if (modelEventNamesRef.current.has(n)) return modelEventNamesRef.current.add(n) es.addEventListener(n, handler as any) }, []) const removeModelEventListener = useCallback((name: string) => { const es = eventSourceRef.current const handler = onModelJobEventRef.current const n = String(name ?? '').trim().toLowerCase() if (!es || !handler || !n) return if (!modelEventNamesRef.current.has(n)) return es.removeEventListener(n, handler as any) modelEventNamesRef.current.delete(n) }, []) const isVisibleDownloadJobForSSE = (job: RecordJob): boolean => { if (isPostworkJobForCount(job)) return false if (isTerminalJobStatusForCount((job as any)?.status)) return false if (Boolean((job as any)?.endedAt)) return false return true } const isVisiblePostworkJobForSSE = (job: RecordJob): boolean => { if (!isPostworkJobForCount(job)) return false if (isTerminalJobStatusForCount((job as any)?.status)) return false return true } 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 applyRoomStateToModel = useCallback(( keyLower: string, patch: { roomStatus?: string isOnline?: boolean imageUrl?: string chatRoomUrl?: string } ) => { if (!keyLower) return setModelsByKey((prev) => { const cur = prev[keyLower] if (!cur) return prev const next: StoredModel = { ...cur, roomStatus: patch.roomStatus != null ? String(patch.roomStatus).trim().toLowerCase() : cur.roomStatus, isOnline: patch.isOnline != null ? Boolean(patch.isOnline) : cur.isOnline, imageUrl: patch.imageUrl && String(patch.imageUrl).trim() ? String(patch.imageUrl).trim() : cur.imageUrl, chatRoomUrl: patch.chatRoomUrl && String(patch.chatRoomUrl).trim() ? String(patch.chatRoomUrl).trim() : cur.chatRoomUrl, } return { ...prev, [keyLower]: next } }) setPendingWatchedRooms((prev) => { const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower) if (idx < 0) return prev const copy = [...prev] copy[idx] = { ...copy[idx], currentShow: normalizePendingShow(patch.roomStatus), imageUrl: patch.imageUrl && String(patch.imageUrl).trim() ? String(patch.imageUrl).trim() : copy[idx].imageUrl, } return copy }) }, []) type AppModelsSnapshot = { totalCount: number onlineModelsCount: number onlineWatchedModelsCount: number onlineFavCount: number onlineLikedCount: number models: StoredModel[] } const [headerStats, setHeaderStats] = useState({ totalCount: 0, onlineModelsCount: 0, onlineWatchedModelsCount: 0, onlineFavCount: 0, onlineLikedCount: 0, }) useEffect(() => { setModelsCount(headerStats.totalCount) }, [headerStats.totalCount]) const overviewInFlightRef = useRef(false) const overviewLastSigRef = useRef('') const overviewLastAtRef = useRef(0) const loadAppModelsSnapshot = useCallback(async (keys: string[]) => { const uniqKeys = Array.from( new Set( keys .map((x) => String(x ?? '').trim().toLowerCase()) .filter(Boolean) ) ) const sig = uniqKeys.join('|') const now = Date.now() if (overviewInFlightRef.current) return if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return overviewInFlightRef.current = true overviewLastSigRef.current = sig overviewLastAtRef.current = now try { const data = await apiJSON('/api/models/overview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store' as any, body: JSON.stringify({ keys: uniqKeys, includeWatched: true, }), }) setHeaderStats({ totalCount: Number(data?.totalCount ?? 0), onlineModelsCount: Number(data?.onlineModelsCount ?? 0), onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), onlineFavCount: Number(data?.onlineFavCount ?? 0), onlineLikedCount: Number(data?.onlineLikedCount ?? 0), }) const safeList = Array.isArray(data?.models) ? data.models : [] setModelsByKey(buildModelsByKey(safeList)) } catch { // ignore } finally { overviewInFlightRef.current = false } }, [buildModelsByKey]) const maybeNotifyWatchedModelStatusChange = useCallback(( keyLower: string, nextStatusRaw: unknown, patch?: { imageUrl?: string } ) => { if (!keyLower) return if (!recSettingsRef.current.enableNotifications) return if (!roomStatusBaselineReadyRef.current) return const model = modelsByKeyRef.current[keyLower] if (!model?.watching) { lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw) return } const nextStatus = normalizeRoomStatusText(nextStatusRaw) const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) // keine Änderung if (prevStatus === nextStatus) return // keine "erste Sichtung" als Wechsel melden const hadPreviousRealStatus = Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) && prevStatus !== 'unknown' lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus if (!hadPreviousRealStatus) return const displayName = String(model?.modelKey ?? keyLower).trim() || keyLower const imageUrl = String(patch?.imageUrl ?? '').trim() || String(model?.imageUrl ?? '').trim() || undefined const title = nextStatus === 'public' ? `${displayName} ist live` : `${displayName} Status geändert` const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}` notifyRef.current?.info(title, message, { imageUrl, imageAlt: displayName, durationMs: 5000, onClick: () => { window.dispatchEvent( new CustomEvent('open-model-details', { detail: { modelKey: keyLower }, }) ) }, }) }, []) const enqueueStart = useCallback( (item: StartQueueItem) => { const norm0 = normalizeHttpUrl(item.url) if (!norm0) return false const norm = canonicalizeProviderUrl(norm0) // dedupe: gleiche URL nicht 100x in die Queue if (startQueuedSetRef.current.has(norm)) return true startQueuedSetRef.current.add(norm) startQueueRef.current.push({ ...item, url: norm }) // pump einmal pro Tick schedulen if (!pumpStartQueueScheduledRef.current) { pumpStartQueueScheduledRef.current = true queueMicrotask(() => { pumpStartQueueScheduledRef.current = false void pumpStartQueue() }) } return true }, // pumpStartQueue kommt gleich darunter (useCallback), daher eslint ggf. meckert -> ok, wir definieren pumpStartQueue als function declaration unten [] ) useEffect(() => { onModelJobEventRef.current = (ev: MessageEvent) => { try { const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent const modelKey = String(msg?.model ?? '').trim().toLowerCase() if (msg?.type === 'job_upsert') { if (modelKey) { const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() if (roomStatus) { const isOnline = typeof msg?.isOnline === 'boolean' ? Boolean(msg.isOnline) : roomStatus !== 'offline' maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, { imageUrl: String(msg?.modelImageUrl ?? '').trim(), }) applyRoomStateToModel(modelKey, { roomStatus, isOnline, imageUrl: String(msg?.modelImageUrl ?? '').trim(), chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(), }) const pendingUrl = String((pendingAutoStartByKeyRef.current || {})[modelKey] ?? '').trim() if (roomStatus === 'public' && pendingUrl && !busyRef.current) { enqueueStart({ url: pendingUrl, silent: true, pendingKeyLower: modelKey, }) } } } } applyJobDelta(msg) } catch { // ignore } } }, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange]) useEffect(() => { const onOpen = (ev: Event) => { const e = ev as CustomEvent<{ modelKey?: string }> const raw0 = (e.detail?.modelKey ?? '').trim() if (!raw0) return // 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen // Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key const looksLikeKey = !raw0.includes(' ') && !raw0.includes('/') && !raw0.includes('\\') if (looksLikeKey) { const k = raw0.replace(/^@/, '').trim().toLowerCase() if (k) setDetailsModelKey(k) return } // 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren const norm0 = normalizeHttpUrl(raw0) if (!norm0) { // Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist) let k = raw0.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) return } const norm = canonicalizeProviderUrl(norm0) const keyLower = providerKeyLowerFromUrl(norm) if (keyLower) setDetailsModelKey(keyLower) } window.addEventListener('open-model-details', onOpen as any) return () => window.removeEventListener('open-model-details', onOpen as any) }, []) useEffect(() => { const onOpen = (ev: Event) => { const e = ev as CustomEvent<{ jobId?: string; output?: string }> const jobId = String(e.detail?.jobId ?? '').trim() const output = String(e.detail?.output ?? '').trim() let hit: RecordJob | null = null if (jobId) { hit = jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? null } if (!hit && output) { const wanted = baseName(output) hit = jobs.find((j) => baseName(j.output || '') === wanted) ?? doneJobs.find((j) => baseName(j.output || '') === wanted) ?? null } if (hit) { setSplitJob(hit) setSplitModalKey((k) => k + 1) setSplitModalOpen(true) } } window.addEventListener('open-video-splitter', onOpen as EventListener) return () => window.removeEventListener('open-video-splitter', onOpen as EventListener) }, [jobs, doneJobs]) 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) }, []) const recSettingsRef = useRef(recSettings) useEffect(() => { recSettingsRef.current = recSettings }, [recSettings]) const [autostartState, setAutostartState] = useState({ paused: false, pausedByUser: false, pausedByDisk: false, }) useEffect(() => { busyRef.current = busy }, [busy]) useEffect(() => { cookiesRef.current = cookies }, [cookies]) useEffect(() => { jobsRef.current = jobs }, [jobs]) // pending start falls gerade busy const lastClipboardUrlRef = useRef('') // --- START QUEUE (parallel) --- const START_CONCURRENCY = 4 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start type StartQueueItem = { url: string silent: boolean pendingKeyLower?: string // wenn aus pendingAutoStartByKey kommt } const startQueueRef = useRef([]) const startInFlightRef = useRef(0) const startQueuedSetRef = useRef>(new Set()) // dedupe: verhindert Duplikate const pumpStartQueueScheduledRef = useRef(false) const setBusyFromStarts = useCallback(() => { const v = startInFlightRef.current > 0 setBusy(v) busyRef.current = v }, []) async function doStartNow(normUrl: string, silent: boolean): Promise { normUrl = canonicalizeProviderUrl(normUrl) // ✅ Duplicate-running guard (wie vorher) const alreadyRunning = jobsRef.current.some((j) => { if (String(j.status || '').toLowerCase() !== 'running') return false if ((j as any).endedAt) return false const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || '')) const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : '' return jNorm === normUrl }) if (alreadyRunning) return true try { const currentCookies = cookiesRef.current const provider = getProviderFromNormalizedUrl(normUrl) if (!provider) { if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.') return false } if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) { if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') return false } 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: normUrl, cookie: cookieString }), }) if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true // UI sofort aktualisieren (optional) 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 } if (!silent) setError(msg) return false } } async function pumpStartQueue(): Promise { // so viele wie möglich parallel starten while (startInFlightRef.current < START_CONCURRENCY && startQueueRef.current.length > 0) { const next = startQueueRef.current.shift()! startInFlightRef.current++ setBusyFromStarts() void (async () => { try { const ok = await doStartNow(next.url, next.silent) // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen if (ok && next.pendingKeyLower) { removePendingAutoStart(next.pendingKeyLower) } } finally { // dedupe wieder freigeben startQueuedSetRef.current.delete(next.url) startInFlightRef.current = Math.max(0, startInFlightRef.current - 1) setBusyFromStarts() // falls noch was da ist: weiterpumpen if (startQueueRef.current.length > 0) { void pumpStartQueue() } } })() } } 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]) const watchedModelKeysLower = useMemo(() => { const set = new Set() for (const m of Object.values(modelsByKey)) { if (!m?.watching) continue const k = lower(String(m?.modelKey ?? '')) if (!k) continue set.add(k) } return Array.from(set) }, [modelsByKey]) const requiredModelKeys = useMemo(() => { const set = new Set() for (const j of jobs) { const k = modelEventKeyFromJob(j) if (k) set.add(k) } for (const j of doneJobs) { const k = modelEventKeyFromDoneJob(j) if (k) set.add(k) } for (const p of pendingWatchedRooms) { const k = String(p?.modelKey ?? '').trim().toLowerCase() if (k) set.add(k) } if (playerJob) { const k = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase() if (k) set.add(k) } if (detailsModelKey) { const k = String(detailsModelKey ?? '').trim().toLowerCase() if (k) set.add(k) } return Array.from(set).sort() }, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey]) const requiredModelKeysSig = useMemo( () => requiredModelKeys.join('|'), [requiredModelKeys] ) const lastOverviewSigRef = useRef('') useEffect(() => { if (lastOverviewSigRef.current !== requiredModelKeysSig) { lastOverviewSigRef.current = requiredModelKeysSig void loadAppModelsSnapshot(requiredModelKeys) } const onChanged = (ev: Event) => { const e = ev as CustomEvent const detail = e?.detail ?? {} const updated = detail?.model 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)) return } 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)) } return } void loadAppModelsSnapshot(requiredModelKeys) } window.addEventListener('models-changed', onChanged as any) 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 setPendingAutoStartByKey((prev) => { const next = { ...(prev || {}), [keyLower]: url } pendingAutoStartByKeyRef.current = next return next }) setPendingWatchedRooms((prev) => { const nextItem: PendingWatchedRoom = { id: keyLower, modelKey: keyLower, url, currentShow: normalizePendingShow(show), imageUrl: imageUrl || undefined, } const idx = prev.findIndex( (x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower ) if (idx >= 0) { const copy = [...prev] copy[idx] = { ...copy[idx], ...nextItem } return copy } return [nextItem, ...prev] }) }, []) const removePendingAutoStart = useCallback((keyLower: string) => { if (!keyLower) return setPendingAutoStartByKey((prev) => { const next = { ...(prev || {}) } delete next[keyLower] pendingAutoStartByKeyRef.current = next return next }) setPendingWatchedRooms((prev) => prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower) ) }, []) const selectedTabRef = useRef(selectedTab) const donePageRef = useRef(donePage) const doneSortRef = useRef(doneSort) useEffect(() => { donePageRef.current = donePage }, [donePage]) useEffect(() => { doneSortRef.current = doneSort }, [doneSort]) const applyPendingRoomSnapshot = useCallback( ( keyLower: string, snap: { show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' imageUrl?: string chatRoomUrl?: string } ) => { if (!keyLower) return setModelsByKey((prev) => { const cur = prev[keyLower] if (!cur) return prev return { ...prev, [keyLower]: { ...cur, roomStatus: snap.show, isOnline: snap.show !== 'offline', imageUrl: snap.imageUrl || cur.imageUrl, chatRoomUrl: snap.chatRoomUrl || cur.chatRoomUrl, }, } }) setPendingWatchedRooms((prev) => { const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower) if (idx < 0) return prev const copy = [...prev] copy[idx] = { ...copy[idx], currentShow: snap.show, imageUrl: snap.imageUrl || copy[idx].imageUrl, } return copy }) }, [] ) // ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird) const modelsByKeyRef = useRef(modelsByKey) useEffect(() => { modelsByKeyRef.current = modelsByKey }, [modelsByKey]) useEffect(() => { const next: Record = {} for (const [rawKey, model] of Object.entries(modelsByKey || {})) { const key = String(rawKey ?? '').trim().toLowerCase() if (!key) continue const status = normalizeRoomStatusText((model as any)?.roomStatus) next[key] = status } lastKnownRoomStatusByKeyRef.current = next if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) { roomStatusBaselineReadyRef.current = true } }, [modelsByKey]) const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey) useEffect(() => { pendingAutoStartByKeyRef.current = pendingAutoStartByKey }, [pendingAutoStartByKey]) useEffect(() => { const keys = Object.keys(pendingAutoStartByKey || {}) if (keys.length === 0) { setPendingWatchedRooms([]) 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 return { id: key, modelKey: key, url, currentShow: normalizePendingShow(model?.roomStatus), imageUrl: String(model?.imageUrl ?? '').trim() || undefined, } }) setPendingWatchedRooms(next) }, [pendingAutoStartByKey, modelsByKey]) useEffect(() => { if (!recSettings.useChaturbateApi) { setCbOnlineFetchedAt('') return } let cancelled = false let timer: number | null = null let inFlight = false const tick = async () => { if (cancelled || inFlight) return const pendingMap = pendingAutoStartByKeyRef.current || {} const entries = Object.entries(pendingMap) if (entries.length === 0) return if (busyRef.current) return const keys = entries .map(([rawKey]) => String(rawKey ?? '').trim().toLowerCase()) .filter(Boolean) if (keys.length === 0) return inFlight = true try { const res = await fetch('/api/chaturbate/online', { method: 'POST', cache: 'no-store', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ q: keys, }), }) const byKey: Record< string, { show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' imageUrl?: string chatRoomUrl?: string } > = {} if (res.ok) { const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null const rooms = Array.isArray(data?.rooms) ? data.rooms : [] for (const room of rooms) { const key = String(room?.username ?? '').trim().toLowerCase() if (!key) continue byKey[key] = { show: normalizePendingShow(room?.current_show), imageUrl: String(room?.image_url ?? '').trim() || undefined, chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined, } } } for (const key of keys) { const snap = byKey[key] ?? { show: 'unknown' as const, imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined, chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined, } maybeNotifyWatchedModelStatusChange(key, snap.show, { imageUrl: snap.imageUrl, }) applyPendingRoomSnapshot(key, snap) const url = String((pendingMap as any)?.[key] ?? '').trim() if (!url) continue if (snap.show === 'public') { enqueueStart({ url, silent: true, pendingKeyLower: key, }) continue } if (snap.show === 'offline') { removePendingAutoStart(key) continue } // private / hidden / away / unknown bleiben in der Warteschlange } } catch { // ignore } finally { inFlight = false } } const schedule = (ms: number) => { if (cancelled) return timer = window.setTimeout(async () => { await tick() schedule(document.hidden ? 8000 : 3000) }, ms) } schedule(0) return () => { cancelled = true if (timer != null) window.clearTimeout(timer) } }, [ recSettings.useChaturbateApi, applyPendingRoomSnapshot, enqueueStart, maybeNotifyWatchedModelStatusChange, removePendingAutoStart, ]) const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower) useEffect(() => { chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower }, [chaturbateStoreKeysLower]) useEffect(() => { selectedTabRef.current = selectedTab }, [selectedTab]) useEffect(() => { if (!recSettings.useChaturbateApi) { setRoomStatusByModelKey({}) return } const next: Record = {} for (const j of jobs) { const key = chaturbateModelKeyFromJob(j) if (!key) continue const direct = String((j as any)?.roomStatus ?? '').trim().toLowerCase() if (direct) { next[key] = direct continue } const fromModel = String((modelsByKey[key] as any)?.roomStatus ?? '').trim().toLowerCase() if (fromModel) { next[key] = fromModel } } setRoomStatusByModelKey(next) }, [jobs, modelsByKey, recSettings.useChaturbateApi]) useEffect(() => { if (!authed || !recSettings.useChaturbateApi) { setCbOnlineFetchedAt('') return } let cancelled = false let timer: number | null = null const tick = async () => { try { const onlineRes = await fetch('/api/chaturbate/online', { method: 'GET', cache: 'no-store', }) if (!onlineRes.ok) return const data = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null if (cancelled || !data) return const fetchedAt = String(data.fetchedAt ?? '').trim() if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') { setCbOnlineFetchedAt(fetchedAt) } } catch { // ignore } } void tick() timer = window.setInterval(() => { void tick() }, 10_000) return () => { cancelled = true if (timer != null) window.clearInterval(timer) } }, [authed, recSettings.useChaturbateApi]) // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise => { const norm0 = normalizeHttpUrl(rawUrl) if (!norm0) return false const norm = canonicalizeProviderUrl(norm0) 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 (String(j.status || '').toLowerCase() !== 'running') return false // ✅ Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken if ((j as any).endedAt) return false const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || '')) const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : '' return jNorm === norm }) if (alreadyRunning) return true // ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart 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) { let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown' let resolvedImageUrl: string | undefined let resolvedChatRoomUrl: string | undefined try { const onlineRes = await fetch('/api/chaturbate/online', { method: 'POST', cache: 'no-store', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ q: [mkLower] }), }) if (onlineRes.ok) { const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null const room = Array.isArray(onlineData?.rooms) ? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower) : null if (room) { resolvedShow = normalizePendingShow(room.current_show) resolvedImageUrl = String(room.image_url ?? '').trim() || undefined resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined } } } catch { // best effort } applyPendingRoomSnapshot(mkLower, { show: resolvedShow, imageUrl: resolvedImageUrl, chatRoomUrl: resolvedChatRoomUrl, }) if ( resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away' || resolvedShow === 'unknown' ) { queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl) return true } if (resolvedShow === 'offline') { removePendingAutoStart(mkLower) return false } // public => unten normal starten } } catch { const mkLower = providerKeyLowerFromUrl(norm) if (mkLower) { queuePendingAutoStart(mkLower, norm, 'unknown') return 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 }), }) // ✅ verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst, // und kurz danach kommt der Job nochmal über SSE/polling rein. if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true 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 } if (!silent) setError(msg) return false } }, []) // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible 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() let wasHidden = document.hidden const onVisible = () => { const becameVisible = wasHidden && !document.hidden wasHidden = document.hidden if (becameVisible) void load() } window.addEventListener('recorder-settings-updated', onUpdated as EventListener) document.addEventListener('visibilitychange', onVisible) void load() return () => { cancelled = true window.removeEventListener('recorder-settings-updated', onUpdated as EventListener) document.removeEventListener('visibilitychange', onVisible) } }, []) const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies]) const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => { modelsCacheRef.current = null setPlayerModel(null) setPlayerJob(job) setPlayerExpanded(false) setPlayerStartAtSec( typeof startAtSec === 'number' && Number.isFinite(startAtSec) && startAtSec >= 0 ? startAtSec : null ) }, []) const openSplitModal = useCallback((job: RecordJob) => { setSplitJob(job) setSplitModalKey((k) => k + 1) setSplitModalOpen(true) }, []) // ✅ Anzahl Watched Models (aus Store), die online sind const onlineModelsCount = headerStats.onlineModelsCount const onlineWatchedModelsCount = headerStats.onlineWatchedModelsCount const onlineFavCount = headerStats.onlineFavCount const onlineLikedCount = headerStats.onlineLikedCount const runningTabCount = useMemo(() => { const activeDownloads = jobs.filter((j) => { if (isPostworkJobForCount(j)) return false if (isTerminalJobStatusForCount((j as any)?.status)) return false if (Boolean((j as any)?.endedAt)) return false return true }).length const activePostwork = jobs.filter((j) => { if (!isPostworkJobForCount(j)) return false if (isTerminalJobStatusForCount((j as any)?.status)) return false return true }).length const pendingCount = pendingWatchedRooms.length return activeDownloads + activePostwork + pendingCount }, [jobs, pendingWatchedRooms]) const tabs: TabItem[] = [ { id: 'running', label: 'Laufende Downloads', count: runningTabCount, icon: ArrowDownTrayIcon, }, { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount, icon: ArchiveBoxIcon, }, { id: 'models', label: 'Models', count: modelsCount, icon: UserGroupIcon, }, { id: 'categories', label: 'Kategorien', icon: Squares2X2Icon, }, { id: 'settings', label: 'Einstellungen', icon: Cog6ToothIcon, spinIcon: settingsTaskRunning, }, ] const canStart = useMemo(() => sourceUrl.trim().length > 0, [sourceUrl]) // 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]) useEffect(() => { if (!authed) return let es: EventSource | null = null let fallbackTimer: number | null = null const stopFallbackPoll = () => { if (fallbackTimer != null) { window.clearInterval(fallbackTimer) fallbackTimer = null } } const startFallbackPoll = () => { if (fallbackTimer != null) return fallbackTimer = window.setInterval(() => { if (document.hidden) return void loadJobs() void loadTaskStateOnce() if (selectedTabRef.current === 'finished') { void loadDonePage(donePageRef.current, doneSortRef.current) } }, document.hidden ? 60000 : 5000) } const loadTaskStateOnce = async () => { try { const res = await fetch('/api/tasks/status', { cache: 'no-store' as any }) if (!res.ok) return const data = await res.json().catch(() => null) setSettingsTaskRunning( Boolean( data?.anyRunning || data?.generateAssets?.running || data?.regenerateAssets?.running || data?.cleanup?.running ) ) } catch { // ignore } } const applyAutostartState = (data: unknown) => { const d = (data ?? {}) as any setAutostartState({ paused: Boolean(d?.paused), pausedByUser: Boolean(d?.pausedByUser), pausedByDisk: Boolean(d?.pausedByDisk), }) } const onDoneChanged = () => { if (selectedTabRef.current !== 'finished') return void loadDonePage(donePageRef.current, doneSortRef.current) } const onAutostart = (ev: MessageEvent) => { try { applyAutostartState(JSON.parse(String(ev.data ?? 'null'))) } catch {} } const onFinishedPostwork = (ev: MessageEvent) => { try { const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent const file = String(msg?.file ?? '').trim() if (!file) return const state = msg?.state === 'queued' || msg?.state === 'running' || msg?.state === 'done' || msg?.state === 'error' || msg?.state === 'missing' ? msg.state : 'missing' window.dispatchEvent( new CustomEvent('finished-downloads:postwork', { detail: { file, assetId: String(msg?.assetId ?? '').trim() || undefined, queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork', state, phase: String(msg?.phase ?? '').trim() || undefined, label: String(msg?.label ?? '').trim() || undefined, error: String(msg?.error ?? '').trim() || undefined, position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined, waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined, running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined, maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined, ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(), }, }) ) // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen if (state === 'done' || state === 'error' || state === 'missing') { bumpAssets() } } catch {} } const onTaskState = (ev: MessageEvent) => { try { const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent setSettingsTaskRunning( Boolean( msg?.anyRunning || msg?.generateAssetsRunning || msg?.regenerateAssetsRunning || msg?.cleanupRunning ) ) } catch {} } void loadJobs() void loadDoneCount() void loadTaskStateOnce() void apiJSON('/api/autostart/state', { cache: 'no-store' as any }) .then((s) => { setAutostartState({ paused: Boolean(s?.paused), pausedByUser: Boolean(s?.pausedByUser), pausedByDisk: Boolean(s?.pausedByDisk), }) }) .catch(() => {}) es = new EventSource('/api/events/stream') eventSourceRef.current = es modelEventNamesRef.current = new Set() es.onopen = () => { stopFallbackPoll() void loadTaskStateOnce() } es.onerror = () => { startFallbackPoll() } es.addEventListener('doneChanged', onDoneChanged as any) es.addEventListener('autostart', onAutostart as any) es.addEventListener('finishedPostwork', onFinishedPostwork as any) es.addEventListener('taskState', onTaskState as any) const onVis = () => { if (document.hidden) return void loadJobs() void loadTaskStateOnce() if (selectedTabRef.current === 'finished') { void loadDonePage(donePageRef.current, doneSortRef.current) } } document.addEventListener('visibilitychange', onVis) return () => { document.removeEventListener('visibilitychange', onVis) stopFallbackPoll() if (es) { es.removeEventListener('doneChanged', onDoneChanged as any) es.removeEventListener('autostart', onAutostart as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any) es.removeEventListener('taskState', onTaskState as any) const handler = onModelJobEventRef.current if (handler) { for (const name of modelEventNamesRef.current) { es.removeEventListener(name, handler as any) } } es.close() } eventSourceRef.current = null modelEventNamesRef.current = new Set() } }, [authed, loadJobs, loadDoneCount]) useEffect(() => { const desired = new Set() // 1) Sichtbare Downloads for (const j of jobs) { if (!isVisibleDownloadJobForSSE(j)) continue const key = modelEventKeyFromJob(j) if (key) desired.add(key) } // 2) Sichtbare Nacharbeiten for (const j of jobs) { if (!isVisiblePostworkJobForSSE(j)) continue const key = modelEventKeyFromJob(j) if (key) desired.add(key) } // 3) Sichtbare Finished-Jobs der aktuellen Seite for (const j of doneJobs) { const key = modelEventKeyFromDoneJob(j) if (key) desired.add(key) } // 4) Sichtbare Wartenden-Einträge for (const p of pendingWatchedRooms) { const key = String(p?.modelKey ?? '').trim().toLowerCase() if (key) desired.add(key) } // 5) watched Models for (const key of watchedModelKeysLower) { if (key) desired.add(key) } // 6) fehlende Listener hinzufügen for (const key of desired) { ensureModelEventListener(key) } // 7) alles entfernen, was nicht mehr sichtbar/relevant ist for (const key of Array.from(modelEventNamesRef.current)) { if (!desired.has(key)) { removeModelEventListener(key) } } }, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener]) 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)) } } async function stopAllJobs() { try { await apiJSON('/api/record/stop-all', { method: 'POST' }) } catch (e: any) { notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) } } async function removeQueuedPostworkJob(id: string) { try { await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, { method: 'POST', }) } catch (e: any) { notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e)) } } useEffect(() => { const onHint = (ev: Event) => { const e = ev as CustomEvent<{ delta?: number }> const delta = Number(e.detail?.delta ?? 0) if (Number.isFinite(delta) && delta !== 0) { setDoneCount((c) => Math.max(0, c + delta)) } } window.addEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) }, []) useEffect(() => { const onNav = (ev: Event) => { const d = (ev as CustomEvent).detail || {} if (d.tab === 'finished') setSelectedTab('finished') if (d.tab === 'categories') setSelectedTab('categories') if (d.tab === 'models') setSelectedTab('models') if (d.tab === 'running') setSelectedTab('running') if (d.tab === 'settings') setSelectedTab('settings') } window.addEventListener('app:navigate-tab', onNav as EventListener) return () => window.removeEventListener('app:navigate-tab', onNav as EventListener) }, []) // ---- 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() { const ok = await startUrl(sourceUrl, { silent: false }) if (ok) { setSourceUrl('') } return ok } const handleAddToDownloads = useCallback( async (job: RecordJob): Promise => { const raw = String( (job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? (job as any)?.sourceURL ?? (job as any)?.url ?? '' ).trim() const url0 = extractFirstUrl(raw) if (!url0) { notify.error('Konnte URL nicht hinzufügen', 'Für diesen Eintrag ist keine Source-URL vorhanden.') return false } const norm0 = normalizeHttpUrl(url0) if (!norm0) { notify.error('Konnte URL nicht hinzufügen', 'Die Source-URL ist ungültig.') return false } const url = canonicalizeProviderUrl(norm0) const ok = await startUrl(url, { silent: true }) if (!ok) { notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.') } return ok }, [startUrl, notify] ) const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => { const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase() if (keyLower) { removePendingAutoStart(keyLower) return } const url = String(pending?.url ?? '').trim() if (!url) return setPendingWatchedRooms((prev) => prev.filter((x) => String(x?.url ?? '').trim() !== url) ) }, [removePendingAutoStart]) type FinishedFileActionKind = 'delete' | 'keep' | 'rename' async function runFinishedFileAction(opts: { kind: FinishedFileActionKind file: string run: () => Promise onSuccess?: (result: T) => void | Promise onError?: (err: unknown) => void | Promise }) { const { kind, file, run, onSuccess, onError } = opts // Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row) if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }) ) } try { const result = await run() if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }) ) } await onSuccess?.(result) return result } catch (e) { if (kind === 'delete' || kind === 'keep') { window.dispatchEvent( new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }) ) } await onError?.(e) throw e } } const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => { const removedFile = String(opts?.removedFile ?? '').trim() setDoneJobs((prev) => { const filtered = removedFile ? prev.filter((j) => baseName(j.output || '') !== removedFile) : [...prev] const need = Math.max(0, donePageSize - filtered.length) if (need <= 0) return filtered const prefetchKey = makePrefetchKey(donePage + 1, doneSort) const buf = donePrefetchRef.current if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) { return filtered } const next: RecordJob[] = [...filtered] const used = new Set( next.map((x) => String(x.id || baseName(x.output || '')).trim()) ) while (next.length < donePageSize && buf.items.length > 0) { const cand = buf.items.shift()! const id = String(cand.id || baseName(cand.output || '')).trim() if (!id || used.has(id)) continue used.add(id) next.push(cand) } donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts, } return next }) }, [donePage, donePageSize, doneSort]) const handleDeleteJobWithUndo = useCallback( async (job: RecordJob): Promise => { const file = baseName(job.output || '') if (!file) return try { const data = await runFinishedFileAction<{ undoToken?: string }>({ kind: 'delete', file, run: () => apiJSON<{ undoToken?: string }>( `/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST' } ), onSuccess: async () => { window.setTimeout(() => { fillDonePageFromPrefetch({ removedFile: file }) setDoneCount((c) => Math.max(0, c - 1)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) const nextDoneCount = Math.max(0, doneCount - 1) const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize) if (donePage < totalPagesAfterDelete) { void prefetchDonePage(donePage + 1) } void loadDoneCount() }, 320) }, onError: async () => { notify.error('Löschen fehlgeschlagen', file) }, }) const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : '' return undoToken ? { undoToken } : {} } catch { return } }, [ runFinishedFileAction, notify, fillDonePageFromPrefetch, donePage, prefetchDonePage, doneCount, loadDoneCount, ] ) const handleKeepJob = useCallback( async (job: RecordJob): Promise => { const file = baseName(job.output || '') if (!file) return try { await runFinishedFileAction({ kind: 'keep', file, run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), onSuccess: async () => { window.setTimeout(() => { fillDonePageFromPrefetch({ removedFile: file }) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setDoneCount((c) => Math.max(0, c - 1)) const nextDoneCount = Math.max(0, doneCount - 1) const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount, donePageSize) if (donePage < totalPagesAfterKeep) { void prefetchDonePage(donePage + 1) } void loadDoneCount() }, 320) }, onError: async () => { notify.error('Keep fehlgeschlagen', file) }, }) } catch { return } }, [ runFinishedFileAction, notify, fillDonePageFromPrefetch, donePage, prefetchDonePage, loadDoneCount, doneCount, ] ) const handleToggleHot = useCallback( async (job: RecordJob): Promise => { const file = baseName(job.output || '') if (!file) return try { // ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) await new Promise((r) => window.setTimeout(r, 60)) const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>( `/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' } ) const oldFile = baseName(res.oldFile || file) || file const newFile = baseName(res.newFile || '') || '' if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück') // ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details) window.dispatchEvent( new CustomEvent('finished-downloads:rename', { detail: { oldFile, newFile }, }) ) const apply = (out: string) => replaceBasename(out || '', newFile) // ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei setPlayerJob((prev) => { if (!prev) return prev const match = prev.id === job.id || baseName(prev.output || '') === oldFile return match ? { ...prev, output: apply(prev.output || '') } : prev }) // ✅ doneJobs über ID (Fallback basename) setDoneJobs((prev) => prev.map((j) => { const match = j.id === job.id || baseName(j.output || '') === oldFile return match ? { ...j, output: apply(j.output || '') } : j }) ) // ✅ jobs (/record/list) über ID (Fallback basename) setJobs((prev) => prev.map((j) => { const match = j.id === job.id || baseName(j.output || '') === oldFile return match ? { ...j, output: apply(j.output || '') } : j }) ) return res } catch (e: any) { notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output)) return } }, [notify] ) const handleDeleteJob = useCallback( async (job: RecordJob): Promise => { await handleDeleteJobWithUndo(job) }, [handleDeleteJobWithUndo] ) // --- 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) const { modelKey, host } = modelKeyAndHostFromJob(job) if (!modelKey) { notify.error('Favorit umschalten fehlgeschlagen', 'Kein modelKey ableitbar') return } const guardKey = modelKey if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[modelKey] ?? ({ id: '', input: '', host: host || undefined, modelKey, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextFav = !Boolean(prev.favorite) const optimistic: StoredModel = { ...prev, host: prev.host || host || undefined, modelKey, favorite: nextFav, liked: nextFav ? false : prev.liked, } setModelsByKey((p) => ({ ...p, [modelKey]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(prev.id ? { id: prev.id } : {}), host: optimistic.host || '', modelKey, favorite: nextFav, ...(nextFav ? { liked: false } : {}), }) if (!updated) { setModelsByKey((p) => { const { [modelKey]: _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 } }) ) return } const k = lower(updated.modelKey || 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) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, 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 { modelKey, host } = modelKeyAndHostFromJob(job) if (!modelKey) { notify.error('Like umschalten fehlgeschlagen', 'Kein modelKey ableitbar') return } const guardKey = modelKey if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[modelKey] ?? ({ id: '', input: '', host: host || undefined, modelKey, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextLiked = !(prev.liked === true) const optimistic: StoredModel = { ...prev, host: prev.host || host || undefined, modelKey, liked: nextLiked, favorite: nextLiked ? false : prev.favorite, } setModelsByKey((p) => ({ ...p, [modelKey]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(prev.id ? { id: prev.id } : {}), host: optimistic.host || '', modelKey, liked: nextLiked, ...(nextLiked ? { favorite: false } : {}), }) if (!updated) { setModelsByKey((p) => { const { [modelKey]: _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 } }) ) return } const k = lower(updated.modelKey || 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) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, 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 { modelKey, host } = modelKeyAndHostFromJob(job) if (!modelKey) { notify.error('Watched umschalten fehlgeschlagen', 'Kein modelKey ableitbar') return } const guardKey = modelKey if (flagsInFlightRef.current[guardKey]) return flagsInFlightRef.current[guardKey] = true const prev = modelsByKey[modelKey] ?? ({ id: '', input: '', host: host || undefined, modelKey, watching: false, favorite: false, liked: null, isUrl: false, } as StoredModel) const nextWatching = !Boolean(prev.watching) const optimistic: StoredModel = { ...prev, host: prev.host || host || undefined, modelKey, watching: nextWatching, } setModelsByKey((p) => ({ ...p, [modelKey]: optimistic })) upsertModelCache(optimistic) if (sameAsPlayer) setPlayerModel(optimistic) try { const updated = await patchModelFlags({ ...(prev.id ? { id: prev.id } : {}), host: optimistic.host || '', modelKey, watched: nextWatching, }) if (!updated) { setModelsByKey((p) => { const { [modelKey]: _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 } }) ) return } const k = lower(updated.modelKey || 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) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) if (sameAsPlayer) setPlayerModel(prev) notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e)) } finally { delete flagsInFlightRef.current[guardKey] } }, [notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey] ) // 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 url0 = extractFirstUrl(text) if (!url0) return const norm0 = normalizeHttpUrl(url0) if (!norm0) return const provider = getProviderFromNormalizedUrl(norm0) if (!provider) return const url = canonicalizeProviderUrl(norm0) if (url === lastClipboardUrlRef.current) return lastClipboardUrlRef.current = url if (autoAddEnabled) setSourceUrl(url) if (autoStartEnabled) { // ✅ immer enqueue (dedupe verhindert doppelt) enqueueStart({ url, silent: false }) } } 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('focus', kick) document.addEventListener('visibilitychange', kick) schedule(0) return () => { cancelled = true if (timer) window.clearTimeout(timer) window.removeEventListener('focus', kick) document.removeEventListener('visibilitychange', kick) } }, [autoAddEnabled, autoStartEnabled, enqueueStart]) if (!authChecked) { return
Lade…
} if (!authed) { return } return (