// frontend\src\App.tsx import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } 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 Settings from './components/ui/Settings' import FinishedDownloads from './components/ui/FinishedDownloads' import Player from './components/ui/Player' import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } 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, StarIcon, EyeIcon, ArrowDownTrayIcon, ArchiveBoxIcon, UserGroupIcon, Squares2X2Icon, Cog6ToothIcon, BeakerIcon, CircleStackIcon, ArrowRightOnRectangleIcon, PlayIcon, } from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' 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' import TrainingTab from './components/ui/TrainingTab' const COOKIE_STORAGE_KEY = 'record_cookies' const MODELS_OVERVIEW_DEBOUNCE_MS = 750 const MODELS_OVERVIEW_COOLDOWN_MS = 5000 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 } const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', ffmpegPath: '', autoAddToDownloadList: false, autoStartAddedDownloads: false, enableConcurrentDownloadsLimit: false, maxConcurrentDownloads: 20, useProviderApis: false, useChaturbateApi: false, useMyFreeCamsApi: false, autoDeleteSmallDownloads: false, autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsKeepFavorites: true, blurPreviews: false, teaserPlayback: 'hover', teaserAudio: false, lowDiskPauseBelowGB: 5, enableNotifications: true, generateAssetsMeta: true, generateAssetsThumb: true, generateAssetsTeaser: true, generateAssetsSprites: true, generateAssetsAnalyze: true, enrichPostworkEnabled: true, trainingRecognitionEnabled: true, trainingDetectorEpochs: 60, trainingPerformanceMode: 'auto', trainingPowerSaveMode: false, trainingCpuThreads: 0, trainingWorkers: 2, trainingYoloBatchSize: 0, trainingLowPriority: false, trainingVideoMAEEnabled: true, } type StoredModel = { id: string input: string host?: string modelKey: string watching: boolean favorite?: boolean liked?: boolean | null tags?: string isUrl?: boolean path?: string roomStatus?: string isOnline?: boolean chatRoomUrl?: string imageUrl?: string gender?: string | null sex?: string | null subgender?: string | null country?: string | null lastOnlineAt?: string lastOfflineAt?: string lastRoomSyncAt?: string } type PendingWatchedRoom = { id: string modelKey: string url: string currentShow: string // private/hidden/away/public/unknown imageUrl?: string } type ChaturbateOnlineRoomItem = { username?: string current_show?: string chat_room_url?: string image_url?: string } type ChaturbateOnlineResponse = { enabled?: boolean fetchedAt?: string lastAttempt?: string count?: number total?: number lastError?: string rooms?: ChaturbateOnlineRoomItem[] } type AutostartState = { paused?: boolean pausedByUser?: boolean pausedByDisk?: boolean } type PendingAutoStartMode = 'wait_public' | 'probe_retry' type PendingAutoStartSource = 'manual' | 'watched' type PendingAutoStartItem = { modelKey: string url: string mode?: PendingAutoStartMode nextProbeAtMs?: number currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' imageUrl?: string source?: PendingAutoStartSource } type PendingAutoStartResponse = { items?: PendingAutoStartItem[] } function mergeStoredModel( prev?: StoredModel | null, incoming?: StoredModel | null ): StoredModel { if (!prev && !incoming) { return { id: '', input: '', modelKey: '', watching: false, } } if (!prev) return incoming as StoredModel if (!incoming) return prev return { ...prev, ...incoming, id: incoming.id || prev.id, input: incoming.input || prev.input, host: incoming.host || prev.host, modelKey: incoming.modelKey || prev.modelKey, watching: incoming.watching ?? prev.watching ?? false, favorite: incoming.favorite ?? prev.favorite, liked: incoming.liked ?? prev.liked, tags: incoming.tags ?? prev.tags, isUrl: incoming.isUrl ?? prev.isUrl, path: incoming.path || prev.path, roomStatus: incoming.roomStatus ?? prev.roomStatus, isOnline: incoming.isOnline ?? prev.isOnline, chatRoomUrl: incoming.chatRoomUrl || prev.chatRoomUrl, imageUrl: incoming.imageUrl || prev.imageUrl, gender: incoming.gender ?? prev.gender ?? null, sex: incoming.sex ?? prev.sex ?? null, subgender: incoming.subgender ?? prev.subgender ?? null, lastOnlineAt: incoming.lastOnlineAt ?? prev.lastOnlineAt, lastOfflineAt: incoming.lastOfflineAt ?? prev.lastOfflineAt, lastRoomSyncAt: incoming.lastRoomSyncAt ?? prev.lastRoomSyncAt, } } function storedModelShallowEqual(a?: StoredModel | null, b?: StoredModel | null) { if (a === b) return true if (!a || !b) return false const keys = new Set([...Object.keys(a), ...Object.keys(b)]) for (const key of keys) { if (!Object.is((a as any)[key], (b as any)[key])) { return false } } return true } 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 } async function canReadClipboardWithoutPrompt(): Promise { if (!navigator.clipboard?.readText) return false const permissions = (navigator as Navigator & { permissions?: { query?: (descriptor: { name: string }) => Promise<{ state?: string }> } }).permissions if (!permissions?.query) return false try { const status = await permissions.query({ name: 'clipboard-read' }) return status.state === 'granted' } catch { return false } } 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 direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') .trim() .toLowerCase() if (direct) return direct 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 direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') .trim() .toLowerCase() if (direct) return direct 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 direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '') .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 (direct) { return { modelKey: direct, host } } const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '') .trim() .toLowerCase() 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 }) } function TrainingBeakerIcon(props: React.ComponentProps) { const { className } = props return (