nsfwapp/frontend/src/App.tsx
2026-07-13 15:01:58 +02:00

4988 lines
153 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, string> | null | undefined): Record<string, string> {
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<T>(url: string, init?: RequestInit): Promise<T> {
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<T>
}
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<boolean> {
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/<name>/...
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: #<name>)
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/#<name>
// https://www.myfreecams.com/#/models/<name>
// https://www.myfreecams.com/<name> (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<string, RecordJob>()
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<typeof BeakerIcon>) {
const { className } = props
return (
<span
className={[
'training-beaker-icon relative inline-block overflow-visible',
className ?? '',
].join(' ')}
aria-hidden="true"
>
<BeakerIcon className="block h-full w-full" />
<span className="training-beaker-bubble training-beaker-bubble-1" />
<span className="training-beaker-bubble training-beaker-bubble-2" />
<span className="training-beaker-bubble training-beaker-bubble-3" />
</span>
)
}
function normalizeTitleProgress(value: unknown): number | null {
const n = Number(value)
if (!Number.isFinite(n)) return null
// Backend-Training kommt als 0..100.
// Analysis-Progress kommt als 0..1, wird aber für den Title nicht mehr benutzt.
const normalized = n > 1 ? n / 100 : n
return Math.max(0, Math.min(1, normalized))
}
export default function App() {
const [trainingTabRunning, setTrainingTabRunning] = useState(false)
const [trainingTitleProgress, setTrainingTitleProgress] = useState<number | null>(null)
const [trainingImageExpanded, setTrainingImageExpanded] = useState(false)
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
useEffect(() => {
const baseTitle = 'Recorder'
if (trainingTabRunning) {
const progress =
typeof trainingTitleProgress === 'number' && Number.isFinite(trainingTitleProgress)
? ` ${Math.round(Math.max(0, Math.min(1, trainingTitleProgress)) * 100)}%`
: ''
document.title = `${baseTitle} · Training${progress}`
return
}
document.title = baseTitle
}, [trainingTabRunning, trainingTitleProgress])
useEffect(() => {
return () => {
for (const t of Object.values(splitProgressClearTimersRef.current)) {
window.clearTimeout(t)
}
}
}, [])
const clearSplitProgressTimer = useCallback((file: string) => {
const t = splitProgressClearTimersRef.current[file]
if (t != null) {
window.clearTimeout(t)
delete splitProgressClearTimersRef.current[file]
}
}, [])
const scheduleSplitProgressCleanup = useCallback((file: string, delayMs: number) => {
clearSplitProgressTimer(file)
splitProgressClearTimersRef.current[file] = window.setTimeout(() => {
setSplitProgressByFile((prev) => {
if (!(file in prev)) return prev
const next = { ...prev }
delete next[file]
return next
})
delete splitProgressClearTimersRef.current[file]
}, delayMs)
}, [clearSplitProgressTimer])
const pushSplitProgress = useCallback((
file: string,
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
) => {
clearSplitProgressTimer(file)
setSplitProgressByFile((prev) => ({
...prev,
[file]: {
sourceFile: file,
startedAtMs: prev[file]?.startedAtMs ?? Date.now(),
...progress,
},
}))
if (progress.phase === 'done') {
scheduleSplitProgressCleanup(file, 8000)
} else if (progress.phase === 'error') {
scheduleSplitProgressCleanup(file, 15000)
}
}, [clearSplitProgressTimer, scheduleSplitProgressCleanup])
const [authChecked, setAuthChecked] = useState(false)
const [authed, setAuthed] = useState(false)
const sourceUrlInputRef = useRef<HTMLInputElement | null>(null)
const notify = useNotify()
const notifyRef = useRef(notify)
const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
const roomStatusBaselineReadyRef = useRef(false)
const pendingJobEventsRef = useRef<JobEvent[]>([])
const pendingJobEventsTimerRef = useRef<number | null>(null)
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
const cookieProblemLastAtRef = useRef(0)
const donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false)
const doneCountInFlightRef = useRef(false)
const doneCountLastAtRef = useRef(0)
const pendingLoadInFlightRef = useRef(false)
const pendingLoadLastAtRef = useRef(0)
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const [detailsModelKey, setDetailsModelKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [cookieModalOpen, setCookieModalOpen] = useState(false)
const [cookies, setCookies] = useState<Record<string, string>>({})
const [cookiesLoaded, setCookiesLoaded] = useState(false)
const [selectedTab, setSelectedTab] = useState('running')
const [settingsTaskRunning, setSettingsTaskRunning] = useState(false)
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false)
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
const [splitJob, setSplitJob] = useState<RecordJob | null>(null)
const [splitModalOpen, setSplitModalOpen] = useState(false)
const [splitModalKey, setSplitModalKey] = useState(0)
const [assetNonce, setAssetNonce] = useState(0)
const [assetNonceByFile, setAssetNonceByFile] = useState<Record<string, number>>({})
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
const bumpAssetForFile = useCallback((fileOrPath: string) => {
const file = baseName(fileOrPath)
if (!file) {
bumpAssets()
return
}
const plainFile = stripHotPrefix(file)
setAssetNonceByFile((prev) => {
const next = { ...prev }
next[file] = (next[file] ?? 0) + 1
if (plainFile && plainFile !== file) {
next[plainFile] = (next[plainFile] ?? 0) + 1
}
return next
})
}, [bumpAssets])
const [recSettings, setRecSettings] = useState<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS)
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})
const [pendingAutoStartSourceByKey, setPendingAutoStartSourceByKey] = useState<Record<string, PendingAutoStartSource>>({})
const pendingAutoStartSourceByKeyRef = useRef(pendingAutoStartSourceByKey)
useEffect(() => {
pendingAutoStartSourceByKeyRef.current = pendingAutoStartSourceByKey
}, [pendingAutoStartSourceByKey])
const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey)
useEffect(() => {
pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey
}, [pendingAutoStartModeByKey])
const pendingBlindRetryAtByKeyRef = useRef(pendingBlindRetryAtByKey)
useEffect(() => {
pendingBlindRetryAtByKeyRef.current = pendingBlindRetryAtByKey
}, [pendingBlindRetryAtByKey])
// "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
const busyRef = useRef(false)
const cookiesRef = useRef<Record<string, string>>({})
const jobsRef = useRef<RecordJob[]>([])
// ✅ "Job gestartet" Toast: dedupe (auch gegen SSE/polling) + initial-load suppression
const startedToastByJobIdRef = useRef<Record<string, true>>({})
const jobsInitDoneRef = useRef(false)
const lower = (s: string) => (s || '').toLowerCase().trim()
const eventSourceRef = useRef<EventSource | null>(null)
const modelEventNamesRef = useRef<Set<string>>(new Set())
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
const lastSeenAtByJobIdRef = useRef<Record<string, number>>({})
const MISSING_JOB_GRACE_MS = 20_000
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1)
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [cbOnlineFetchedAt, setCbOnlineFetchedAt] = useState<string>('')
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const [includeKeep, setIncludeKeep] = useState<boolean>(() => {
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({})
setPendingAutoStartModeByKey({})
setPendingBlindRetryAtByKey({})
setPendingAutoStartSourceByKey({})
pendingAutoStartByKeyRef.current = {}
pendingAutoStartModeByKeyRef.current = {}
pendingBlindRetryAtByKeyRef.current = {}
pendingAutoStartSourceByKeyRef.current = {}
// optional: URL-Feld leeren
setSourceUrl('')
}
}, [])
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 = 8
const [donePageSize, setDonePageSize] = useState<number>(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<DoneSortMode>(() => {
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[]
totalCount: number
ts: number
}
const makePrefetchKey = (
page: number,
sort: DoneSortMode,
pageSize: number,
keep: boolean
) => `${sort}::${pageSize}::${keep ? 1 : 0}::${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, donePageSize, includeKeep)
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)}&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[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const totalCountRaw = Number(data?.count ?? data?.totalCount ?? 0)
const totalCount =
Number.isFinite(totalCountRaw) && totalCountRaw >= 0
? totalCountRaw
: 0
donePrefetchRef.current = {
key,
items,
totalCount,
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,
donePageSize,
includeKeep
)
const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs(prefetched.items)
const count = Number(prefetched.totalCount)
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
setDoneCount(count)
}
donePrefetchRef.current = null
const totalPages = getDoneTotalPages(count, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
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])
const loadDonePageRef = useRef(loadDonePage)
useEffect(() => {
loadDonePageRef.current = loadDonePage
}, [loadDonePage])
const doneRefreshTimerRef = useRef<number | null>(null)
const doneRefreshInFlightRef = useRef(false)
const doneRefreshQueuedRef = useRef(false)
const scheduleDoneRefresh = useCallback((delayMs = 450) => {
if (doneRefreshTimerRef.current != null) {
window.clearTimeout(doneRefreshTimerRef.current)
}
doneRefreshTimerRef.current = window.setTimeout(() => {
doneRefreshTimerRef.current = null
if (doneRefreshInFlightRef.current) {
doneRefreshQueuedRef.current = true
return
}
doneRefreshInFlightRef.current = true
void (async () => {
try {
donePrefetchRef.current = null
await loadDonePageRef.current(donePageRef.current, doneSortRef.current)
await loadDoneCount()
} finally {
doneRefreshInFlightRef.current = false
if (doneRefreshQueuedRef.current) {
doneRefreshQueuedRef.current = false
scheduleDoneRefresh(300)
}
}
})()
}, Math.max(0, delayMs))
}, [loadDoneCount])
useEffect(() => {
return () => {
if (doneRefreshTimerRef.current != null) {
window.clearTimeout(doneRefreshTimerRef.current)
doneRefreshTimerRef.current = null
}
}
}, [])
useEffect(() => {
try {
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
} catch {
// ignore
}
}, [includeKeep])
useEffect(() => {
if (!authed) {
setTrainingTabRunning(false)
return
}
let cancelled = false
const loadTrainingStatus = async () => {
try {
const res = await fetch('/api/training/status', { cache: 'no-store' as any })
const data = await res.json().catch(() => null)
if (cancelled || !res.ok || !data) return
setTrainingTabRunning(Boolean(data?.training?.running))
setTrainingTitleProgress(
normalizeTitleProgress(data?.training?.progress ?? data?.progress)
)
} catch {
// ignore
}
}
void loadTrainingStatus()
const onVisibilityChange = () => {
if (!document.hidden) {
void loadTrainingStatus()
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
cancelled = true
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [authed])
useEffect(() => {
try {
const appWindow = window as any
appWindow.__nsfwappTrainingRunning = trainingTabRunning
window.dispatchEvent(
new CustomEvent('app:training-running', {
detail: { running: trainingTabRunning },
})
)
} catch {
// ignore
}
}, [trainingTabRunning])
const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => {
const force = Boolean(opts?.force)
const now = Date.now()
if (pendingLoadInFlightRef.current) return
if (!force && now - pendingLoadLastAtRef.current < 1500) return
pendingLoadInFlightRef.current = true
pendingLoadLastAtRef.current = now
try {
const data = await apiJSON<PendingAutoStartResponse>('/api/pending-autostart', {
cache: 'no-store' as any,
})
const items = Array.isArray(data?.items) ? data.items : []
const nextByKey: Record<string, string> = {}
const nextModeByKey: Record<string, PendingAutoStartMode> = {}
const nextBlindRetryAtByKey: Record<string, number> = {}
const nextSourceByKey: Record<string, PendingAutoStartSource> = {}
for (const item of items) {
const keyLower = String(item?.modelKey ?? '').trim().toLowerCase()
const norm0 = normalizeHttpUrl(String(item?.url ?? ''))
const mode: PendingAutoStartMode =
String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public'
const source: PendingAutoStartSource =
String(item?.source ?? '').trim() === 'watched' ? 'watched' : 'manual'
if (!keyLower || !norm0) continue
const norm = canonicalizeProviderUrl(norm0)
nextByKey[keyLower] = norm
nextModeByKey[keyLower] = mode
nextSourceByKey[keyLower] = source
if (mode === 'probe_retry') {
const ts = Number(item?.nextProbeAtMs ?? 0)
nextBlindRetryAtByKey[keyLower] =
Number.isFinite(ts) && ts > 0 ? ts : Date.now() + 60_000
}
}
setPendingAutoStartByKey(nextByKey)
pendingAutoStartByKeyRef.current = nextByKey
setPendingAutoStartModeByKey(nextModeByKey)
pendingAutoStartModeByKeyRef.current = nextModeByKey
setPendingBlindRetryAtByKey(nextBlindRetryAtByKey)
pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey
setPendingAutoStartSourceByKey(nextSourceByKey)
pendingAutoStartSourceByKeyRef.current = nextSourceByKey
} catch {
// ignore
} finally {
pendingLoadInFlightRef.current = false
}
}, [])
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<string, RecordJob>()
// 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(() => {
void checkAuth()
}, [checkAuth])
useEffect(() => {
if (!authed) return
void loadPendingAutoStarts()
}, [authed, loadPendingAutoStarts])
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,
model: msg.model ?? (prevJob as any)?.model,
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 idx = prev.findIndex(
(j) => String((j as any).id ?? '').trim() === jobId
)
const prevJob = idx >= 0 ? prev[idx] : null
const merged = mergeJob(prevJob)
// bestehender Job: nur ersetzen, Reihenfolge beibehalten
if (idx >= 0) {
const next = [...prev]
next[idx] = merged
jobsRef.current = next
return next
}
// neuer Job: vorne einfügen
const next = [merged, ...prev]
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 flushQueuedJobEvents = useCallback(() => {
if (pendingJobEventsTimerRef.current != null) {
window.clearTimeout(pendingJobEventsTimerRef.current)
pendingJobEventsTimerRef.current = null
}
const batch = pendingJobEventsRef.current
if (batch.length === 0) return
pendingJobEventsRef.current = []
// pro Job nur das letzte Event behalten
const byJobId = new Map<string, JobEvent>()
const passthrough: JobEvent[] = []
for (const msg of batch) {
const type = String(msg?.type ?? '').trim()
const jobId = String(msg?.jobId ?? '').trim()
if ((type === 'job_upsert' || type === 'job_remove') && jobId) {
byJobId.set(jobId, msg)
continue
}
passthrough.push(msg)
}
for (const msg of byJobId.values()) {
applyJobDelta(msg)
}
for (const msg of passthrough) {
applyJobDelta(msg)
}
}, [applyJobDelta])
const queueJobDelta = useCallback((msg: JobEvent) => {
pendingJobEventsRef.current.push(msg)
if (pendingJobEventsTimerRef.current != null) return
pendingJobEventsTimerRef.current = window.setTimeout(() => {
flushQueuedJobEvents()
}, 120)
}, [flushQueuedJobEvents])
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
const pw = (job as any)?.postWork
const state = String(pw?.state ?? '').trim().toLowerCase()
if (String((job as any)?.postWorkKey ?? '').trim() && (!state || state === 'queued' || state === 'running')) {
return true
}
if (isTerminalJobStatusForCount((job as any)?.status)) return false
return true
}
const buildModelsByKey = useCallback((list: StoredModel[]) => {
const map: Record<string, StoredModel> = {}
const score = (x: StoredModel) =>
(x.favorite ? 4 : 0) +
(x.liked === true ? 2 : 0) +
(x.watching ? 1 : 0)
for (const m of Array.isArray(list) ? list : []) {
const k = String(m?.modelKey ?? '').trim().toLowerCase()
if (!k) continue
const cur = map[k]
if (!cur) {
map[k] = m
continue
}
const better = score(m) >= score(cur) ? m : cur
const other = better === m ? cur : m
map[k] = mergeStoredModel(other, better)
}
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
})
}, [])
const queuePendingAutoStart = useCallback((
keyLower: string,
url: string,
show?: string,
imageUrl?: string,
opts?: {
mode?: PendingAutoStartMode
nextProbeAtMs?: number
source?: PendingAutoStartSource
}
) => {
const key = String(keyLower ?? '').trim().toLowerCase()
const norm0 = normalizeHttpUrl(String(url ?? ''))
if (!key || !norm0) return
const norm = canonicalizeProviderUrl(norm0)
const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public'
const source: PendingAutoStartSource = opts?.source === 'watched' ? 'watched' : 'manual'
const nextProbeAtMs =
mode === 'probe_retry'
? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000))
: undefined
const nextShow = normalizePendingShow(show)
const nextRetryAt =
mode === 'probe_retry' && Number.isFinite(nextProbeAtMs)
? Number(nextProbeAtMs)
: 0
// ✅ ALTE Werte VOR dem State-Update lesen
const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
const prevMode = (pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public') as PendingAutoStartMode
const prevSource = (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource
const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
const sameUrl = prevUrl === norm
const sameMode = prevMode === mode
const sameSource = prevSource === source
const sameRetryAt =
mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [key]: norm }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingAutoStartModeByKey((prev) => {
const next = { ...(prev || {}), [key]: mode }
pendingAutoStartModeByKeyRef.current = next
return next
})
setPendingAutoStartSourceByKey((prev) => {
const next = { ...(prev || {}), [key]: source }
pendingAutoStartSourceByKeyRef.current = next
return next
})
setPendingBlindRetryAtByKey((prev) => {
const next = { ...(prev || {}) }
if (mode === 'probe_retry') {
next[key] = nextRetryAt || (Date.now() + 60_000)
} else {
delete next[key]
}
pendingBlindRetryAtByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: key,
modelKey: key,
url: norm,
currentShow: nextShow,
imageUrl: imageUrl || undefined,
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === key
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
// ✅ Nur persistieren, wenn sich wirklich etwas geändert hat
if (sameUrl && sameMode && sameSource && sameRetryAt) {
return
}
void fetch('/api/pending-autostart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelKey: key,
url: norm,
mode,
nextProbeAtMs: mode === 'probe_retry' ? nextRetryAt : undefined,
currentShow: nextShow,
imageUrl: imageUrl || undefined,
source,
}),
}).catch(() => {})
}, [])
const removePendingAutoStart = useCallback((keyLower: string) => {
const key = String(keyLower ?? '').trim().toLowerCase()
if (!key) return
const hadPending =
Boolean(pendingAutoStartByKeyRef.current[key]) ||
Boolean(pendingAutoStartModeByKeyRef.current[key]) ||
Boolean(pendingBlindRetryAtByKeyRef.current[key]) ||
Boolean(pendingAutoStartSourceByKeyRef.current[key])
if (!hadPending) return
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}) }
delete next[key]
pendingAutoStartByKeyRef.current = next
return next
})
setPendingAutoStartModeByKey((prev) => {
const next = { ...(prev || {}) }
delete next[key]
pendingAutoStartModeByKeyRef.current = next
return next
})
setPendingBlindRetryAtByKey((prev) => {
const next = { ...(prev || {}) }
delete next[key]
pendingBlindRetryAtByKeyRef.current = next
return next
})
setPendingAutoStartSourceByKey((prev) => {
const next = { ...(prev || {}) }
delete next[key]
pendingAutoStartSourceByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) =>
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key)
)
void fetch(`/api/pending-autostart?modelKey=${encodeURIComponent(key)}`, {
method: 'DELETE',
}).catch(() => {})
}, [])
const selectedTabRef = useRef(selectedTab)
const donePageRef = useRef(donePage)
const doneSortRef = useRef(doneSort)
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(() => {
return () => {
if (pendingJobEventsTimerRef.current != null) {
window.clearTimeout(pendingJobEventsTimerRef.current)
}
}
}, [])
useEffect(() => {
setModelsCount(headerStats.totalCount)
}, [headerStats.totalCount])
const handleModelsCountChange = useCallback((count: number) => {
const nextCount = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
setHeaderStats((prev) =>
prev.totalCount === nextCount
? prev
: {
...prev,
totalCount: nextCount,
}
)
}, [])
const overviewInFlightRef = useRef(false)
const overviewLastSigRef = useRef('')
const overviewLastAtRef = useRef(0)
const overviewTimerRef = useRef<number | null>(null)
const overviewAbortRef = useRef<AbortController | null>(null)
const overviewPendingKeysRef = useRef<string[] | null>(null)
const overviewStatsInFlightRef = useRef(false)
const overviewStatsLastAtRef = useRef(0)
const overviewStatsAbortRef = useRef<AbortController | null>(null)
const applyHeaderStatsFromSnapshot = useCallback((data: AppModelsSnapshot | null | undefined) => {
if (!data) return
setHeaderStats((prev) => {
const totalCount = Number(data.totalCount)
const onlineModelsCount = Number(data.onlineModelsCount)
const onlineWatchedModelsCount = Number(data.onlineWatchedModelsCount)
const onlineFavCount = Number(data.onlineFavCount)
const onlineLikedCount = Number(data.onlineLikedCount)
const next = {
totalCount: Number.isFinite(totalCount) ? totalCount : prev.totalCount,
onlineModelsCount: Number.isFinite(onlineModelsCount) ? onlineModelsCount : prev.onlineModelsCount,
onlineWatchedModelsCount: Number.isFinite(onlineWatchedModelsCount)
? onlineWatchedModelsCount
: prev.onlineWatchedModelsCount,
onlineFavCount: Number.isFinite(onlineFavCount) ? onlineFavCount : prev.onlineFavCount,
onlineLikedCount: Number.isFinite(onlineLikedCount) ? onlineLikedCount : prev.onlineLikedCount,
}
return (
prev.totalCount === next.totalCount &&
prev.onlineModelsCount === next.onlineModelsCount &&
prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount &&
prev.onlineFavCount === next.onlineFavCount &&
prev.onlineLikedCount === next.onlineLikedCount
)
? prev
: next
})
}, [])
const mergeModelsSnapshot = useCallback((models: StoredModel[] | undefined | null) => {
const safeList = Array.isArray(models) ? models : []
if (safeList.length === 0) return
const built = buildModelsByKey(safeList)
setModelsByKey((prev) => {
const next = { ...prev }
let changed = false
for (const [k, incoming] of Object.entries(built)) {
const merged = mergeStoredModel(prev[k], incoming)
if (!storedModelShallowEqual(prev[k], merged)) {
next[k] = merged
changed = true
}
}
return changed ? next : prev
})
}, [buildModelsByKey])
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) {
overviewPendingKeysRef.current = uniqKeys
return
}
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
overviewInFlightRef.current = true
const controller = new AbortController()
overviewAbortRef.current = controller
try {
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
signal: controller.signal,
body: JSON.stringify({
keys: uniqKeys,
includeWatched: false,
includeStats: false,
includeModels: true,
}),
})
overviewLastSigRef.current = sig
overviewLastAtRef.current = now
mergeModelsSnapshot(data?.models)
} catch (error: any) {
if (error?.name === 'AbortError') return
// ignore
} finally {
overviewInFlightRef.current = false
if (overviewAbortRef.current === controller) {
overviewAbortRef.current = null
}
const pendingKeys = overviewPendingKeysRef.current
overviewPendingKeysRef.current = null
if (pendingKeys && pendingKeys.join('|') !== sig) {
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
}
overviewTimerRef.current = window.setTimeout(() => {
overviewTimerRef.current = null
void loadAppModelsSnapshot(pendingKeys)
}, MODELS_OVERVIEW_DEBOUNCE_MS)
}
}
}, [mergeModelsSnapshot])
const loadAppModelsStatsAndWatched = useCallback(async (force = false) => {
const now = Date.now()
if (overviewStatsInFlightRef.current) return
if (!force && now - overviewStatsLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
overviewStatsInFlightRef.current = true
const controller = new AbortController()
overviewStatsAbortRef.current = controller
try {
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
signal: controller.signal,
body: JSON.stringify({
keys: [],
includeWatched: true,
includeStats: true,
includeModels: true,
}),
})
overviewStatsLastAtRef.current = Date.now()
applyHeaderStatsFromSnapshot(data)
mergeModelsSnapshot(data?.models)
} catch (error: any) {
if (error?.name === 'AbortError') return
// ignore
} finally {
overviewStatsInFlightRef.current = false
if (overviewStatsAbortRef.current === controller) {
overviewStatsAbortRef.current = null
}
}
}, [applyHeaderStatsFromSnapshot, mergeModelsSnapshot])
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) {
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
// unknown nicht als "letzten echten Status" speichern
if (nextStatus !== 'unknown') {
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
}
return
}
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
// unknown niemals melden und auch nicht als letzten echten Status überschreiben
if (nextStatus === 'unknown') return
// keine Änderung
if (prevStatus === nextStatus) return
// 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: 3000,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: keyLower },
})
)
},
})
}, [])
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 statusLower = String(msg?.status ?? '').trim().toLowerCase()
const phaseLower = String(msg?.phase ?? '').trim().toLowerCase()
const isActiveVisibleRecording =
statusLower === 'running' &&
(phaseLower === '' || phaseLower === 'recording')
if (isActiveVisibleRecording) {
removePendingAutoStart(modelKey)
}
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
if (roomStatus) {
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(),
})
}
}
}
queueJobDelta(msg)
} catch {
// ignore
}
}
}, [applyRoomStateToModel, maybeNotifyWatchedModelStatusChange, queueJobDelta, removePendingAutoStart])
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] = mergeStoredModel(cur.list[idx], m)
} else {
cur.list.unshift(m)
}
}, [])
const recSettingsRef = useRef(recSettings)
useEffect(() => {
recSettingsRef.current = recSettings
}, [recSettings])
const [autostartState, setAutostartState] = useState<AutostartState>({
paused: false,
pausedByUser: false,
pausedByDisk: false,
})
const applyAutostartState = useCallback((data: unknown) => {
const d = (data ?? {}) as any
setAutostartState({
paused: Boolean(d?.paused),
pausedByUser: Boolean(d?.pausedByUser),
pausedByDisk: Boolean(d?.pausedByDisk),
})
}, [])
const loadAutostartState = useCallback(async () => {
const s = await apiJSON<AutostartState>('/api/autostart/state', {
cache: 'no-store' as any,
})
applyAutostartState(s)
}, [applyAutostartState])
useEffect(() => {
busyRef.current = busy
}, [busy])
useEffect(() => {
cookiesRef.current = cookies
}, [cookies])
useEffect(() => {
jobsRef.current = jobs
}, [jobs])
// pending start falls gerade busy
const lastClipboardUrlRef = useRef<string>('')
const chaturbateStoreKeysLower = useMemo(() => {
const set = new Set<string>()
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<string>()
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])
function candidateModelKeysFromJob(job: RecordJob): string[] {
const anyJob = job as any
const rawSource =
String(anyJob?.sourceUrl ?? anyJob?.SourceURL ?? '').trim()
const norm0 = normalizeHttpUrl(rawSource)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
const keyFromUrl = norm ? providerKeyLowerFromUrl(norm) : ''
const candidates = [
anyJob?.model,
anyJob?.modelKey,
anyJob?.meta?.modelKey,
anyJob?.meta?.model?.key,
anyJob?.meta?.model?.name,
keyFromUrl,
modelKeyFromFilename(anyJob?.output || ''),
]
const seen = new Set<string>()
const out: string[] = []
for (const value of candidates) {
const k = String(value ?? '').trim().toLowerCase()
if (!k || seen.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}
const requiredModelKeys = useMemo(() => {
const set = new Set<string>()
for (const j of jobs) {
for (const k of candidateModelKeysFromJob(j)) {
set.add(k)
}
}
for (const j of doneJobs) {
for (const k of candidateModelKeysFromJob(j)) {
set.add(k)
}
}
for (const p of pendingWatchedRooms) {
const k = String(p?.modelKey ?? '').trim().toLowerCase()
if (k) set.add(k)
}
if (playerJob) {
for (const k of candidateModelKeysFromJob(playerJob)) {
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 requiredModelKeysRef = useRef<string[]>(requiredModelKeys)
useEffect(() => {
requiredModelKeysRef.current = requiredModelKeys
}, [requiredModelKeys])
const lastOverviewSigRef = useRef<string | null>(null)
useEffect(() => {
if (!authed) return
const scheduleOverviewLoad = () => {
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
}
overviewTimerRef.current = window.setTimeout(() => {
void loadAppModelsSnapshot(requiredModelKeys)
}, MODELS_OVERVIEW_DEBOUNCE_MS)
}
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
lastOverviewSigRef.current = requiredModelKeysSig
scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(false)
}
const onChanged = (ev: Event) => {
const e = ev as CustomEvent<any>
const detail = e?.detail ?? {}
const updated = detail?.model
if (updated && typeof updated === 'object') {
const k = String(updated.modelKey ?? '').toLowerCase().trim()
const mergedUpdated = k
? mergeStoredModel(modelsByKeyRef.current[k], updated)
: mergeStoredModel(undefined, updated)
if (k) {
setModelsByKey((prev) => ({
...prev,
[k]: mergeStoredModel(prev[k], updated),
}))
}
try {
upsertModelCache(mergedUpdated)
} catch {}
setPlayerModel((prev) =>
prev?.id === updated.id ? mergeStoredModel(prev, updated) : prev
)
scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(true)
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))
}
scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(true)
return
}
scheduleOverviewLoad()
void loadAppModelsStatsAndWatched(false)
}
window.addEventListener('models-changed', onChanged as any)
return () => {
window.removeEventListener('models-changed', onChanged as any)
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
overviewTimerRef.current = null
}
}
}, [authed, loadAppModelsSnapshot, loadAppModelsStatsAndWatched, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
useEffect(() => {
if (authed) return
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
overviewTimerRef.current = null
}
overviewPendingKeysRef.current = null
overviewAbortRef.current?.abort()
overviewAbortRef.current = null
overviewInFlightRef.current = false
overviewStatsAbortRef.current?.abort()
overviewStatsAbortRef.current = null
overviewStatsInFlightRef.current = false
overviewStatsLastAtRef.current = 0
}, [authed])
useEffect(() => {
return () => {
if (overviewTimerRef.current != null) {
window.clearTimeout(overviewTimerRef.current)
overviewTimerRef.current = null
}
overviewPendingKeysRef.current = null
overviewAbortRef.current?.abort()
overviewAbortRef.current = null
overviewInFlightRef.current = false
overviewStatsAbortRef.current?.abort()
overviewStatsAbortRef.current = null
overviewStatsInFlightRef.current = false
overviewStatsLastAtRef.current = 0
}
}, [])
useEffect(() => {
donePageRef.current = donePage
}, [donePage])
useEffect(() => {
doneSortRef.current = doneSort
}, [doneSort])
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
const modelsByKeyRef = useRef(modelsByKey)
useEffect(() => {
modelsByKeyRef.current = modelsByKey
}, [modelsByKey])
useEffect(() => {
const next: Record<string, string> = {}
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
}
setPendingWatchedRooms((prev) => {
const prevByKey = new Map(
prev.map((x) => [String(x.modelKey ?? '').trim().toLowerCase(), x] as const)
)
const next: PendingWatchedRoom[] = keys.map((rawKey) => {
const key = String(rawKey ?? '').trim().toLowerCase()
const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim()
const model = (modelsByKey as any)?.[key] ?? null
const prevItem = prevByKey.get(key)
return {
id: key,
modelKey: key,
url,
currentShow: normalizePendingShow(model?.roomStatus ?? prevItem?.currentShow),
imageUrl:
String(model?.imageUrl ?? '').trim() ||
prevItem?.imageUrl ||
undefined,
}
})
return next
})
}, [pendingAutoStartByKey, modelsByKey])
useEffect(() => {
if (!authed) return
const id = window.setInterval(() => {
void loadPendingAutoStarts()
}, document.hidden ? 8000 : 3000)
return () => window.clearInterval(id)
}, [authed, loadPendingAutoStarts])
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
useEffect(() => {
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
}, [chaturbateStoreKeysLower])
useEffect(() => {
selectedTabRef.current = selectedTab
}, [selectedTab])
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setRoomStatusByModelKey({})
return
}
const next: Record<string, string> = {}
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('')
setHeaderStats((prev) => ({
...prev,
onlineModelsCount: 0,
onlineWatchedModelsCount: 0,
onlineFavCount: 0,
onlineLikedCount: 0,
}))
return
}
let cancelled = false
let timer: number | null = null
let lastSeenFetchedAt = ''
const tick = async (refresh = false) => {
try {
const onlineRes = await fetch(
refresh ? '/api/chaturbate/online?refresh=1' : '/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()
const safeFetchedAt =
fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z' ? fetchedAt : ''
const totalRaw = Number(data.total ?? 0)
const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? totalRaw : 0
// Online-Gesamtcounter sofort aus der Online-API aktualisieren
setHeaderStats((prev) => {
if (prev.onlineModelsCount === total) return prev
return {
...prev,
onlineModelsCount: total,
}
})
setCbOnlineFetchedAt(safeFetchedAt)
// Derselbe erfolgreiche Snapshot triggert den Refresh
// der watched/fav/like Counter + modelsByKey
if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) {
lastSeenFetchedAt = safeFetchedAt
void loadAppModelsStatsAndWatched(true)
void loadAppModelsSnapshot(requiredModelKeysRef.current)
}
} catch {
// ignore
}
}
void tick(true)
timer = window.setInterval(() => {
void tick(false)
}, 10_000)
return () => {
cancelled = true
if (timer != null) window.clearInterval(timer)
}
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot, loadAppModelsStatsAndWatched])
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async (
rawUrl: string,
opts?: { silent?: boolean; immediate?: boolean }
): Promise<boolean> => {
const norm0 = normalizeHttpUrl(rawUrl)
if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0)
const silent = Boolean(opts?.silent)
const immediate = Boolean(opts?.immediate)
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
// Backend-only Autostart:
// Frontend speichert nur pending, Backend startet eigenständig.
const keyLower = providerKeyLowerFromUrl(norm)
// Nur automatische Starts in pending-autostart schieben.
// Manuelle UI-Starts müssen sofort direkt starten.
if (!immediate && !recSettingsRef.current.autoStartAddedDownloads) {
return true
}
if (!immediate) {
if (
provider === 'chaturbate' &&
recSettingsRef.current.useProviderApis &&
recSettingsRef.current.useChaturbateApi &&
keyLower
) {
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
source: 'manual',
})
return true
}
if (
provider === 'mfc' &&
recSettingsRef.current.useProviderApis &&
recSettingsRef.current.useMyFreeCamsApi &&
keyLower
) {
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
mode: 'probe_retry',
source: 'manual',
})
return true
}
}
try {
const cookieString = Object.entries(currentCookies)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
const created = await apiJSON<RecordJob>('/api/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: norm,
cookie: cookieString,
}),
})
// Wichtig: model lokal sofort mitschleppen, damit overview / modelsByKey
// nicht erst nach SSE oder Dateinamen-Erkennung korrekt werden.
const localModelKey = providerKeyLowerFromUrl(norm)
const createdWithModel = localModelKey
? ({ ...created, model: localModelKey } as RecordJob)
: created
// verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
// und kurz danach kommt der Job nochmal über SSE/polling rein.
if ((createdWithModel as any)?.id) {
startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true
}
setJobs((prev) => {
const next = upsertJobById(prev, createdWithModel)
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<RecorderSettingsState>('/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(() => {
return jobs.filter((j) => {
const status = String((j as any)?.status ?? '').trim().toLowerCase()
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
// Nacharbeit / Postwork nicht mitzählen
if (isPostworkJobForCount(j)) return false
// Fertige/abgebrochene/fehlgeschlagene Jobs nicht mitzählen
if (isTerminalJobStatusForCount(status)) return false
// Sobald endedAt gesetzt ist, ist die aktive Aufnahme vorbei
if (Boolean((j as any)?.endedAt)) return false
// Nur wirklich laufende Downloads zählen
return status === 'running' && (phase === '' || phase === 'recording')
}).length
}, [jobs])
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: 'training',
label: 'Training',
icon: trainingTabRunning ? TrainingBeakerIcon : BeakerIcon,
},
{
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<string, string> }>('/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<string, string>
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<string, string>
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') {
scheduleDoneRefresh(500)
}
}, 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 onDoneChanged = () => {
if (selectedTabRef.current === 'finished') {
scheduleDoneRefresh(900)
return
}
void loadDoneCount()
}
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') {
bumpAssetForFile(file)
}
} catch {}
}
const onAnalysisProgress = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
if (msg?.type !== 'analysis_progress') return
const scope = String(msg?.scope ?? '').trim()
if (scope === 'training') {
// Nur an den TrainingTab weiterreichen.
// Wichtig: Bildanalyse darf den Browser-Title NICHT auf "Training ..." setzen.
window.dispatchEvent(
new CustomEvent('app:sse:analysis', {
detail: msg,
})
)
return
}
const file = baseName(String(msg?.file ?? msg?.sourceFile ?? '').trim())
if (!file) return
const phase = String(msg?.phase ?? '').trim().toLowerCase()
const progressRaw = Number(msg?.progress ?? 0)
const progress = Number.isFinite(progressRaw)
? Math.max(0, Math.min(1, progressRaw))
: 0
const totalRaw = Number(msg?.total ?? 0)
const total =
Number.isFinite(totalRaw) && totalRaw > 0
? Math.floor(totalRaw)
: 0
const percent = Math.round(progress * 100)
const message = String(msg?.message ?? '').trim()
const runningLabel =
total > 0
? /\d{1,3}%/.test(message)
? message
: `Analyse ${percent}%`
: message || 'Analyse läuft'
const state =
phase === 'error'
? 'error'
: phase === 'done'
? 'done'
: 'running'
const label =
phase === 'error'
? 'Analyse Fehler'
: phase === 'done'
? 'Analyse fertig'
: runningLabel
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: stripHotPrefix(file.replace(/\.[^.]+$/, '')),
queue: 'enrich',
phase: 'analyze',
state,
label,
error: String(msg?.error ?? '').trim() || undefined,
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
? msg.ts
: Date.now(),
},
})
)
if (state === 'done' || state === 'error') {
bumpAssetForFile(file)
}
} catch {
// ignore
}
}
const onTaskState = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
setSettingsTaskRunning(
Boolean(
msg?.anyRunning ||
msg?.generateAssetsRunning ||
msg?.regenerateAssetsRunning ||
msg?.checkVideosRunning ||
msg?.cleanupRunning
)
)
} catch {}
}
const onTraining = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'training_status') return
const running = Boolean(data?.training?.running)
setTrainingTabRunning(running)
setTrainingTitleProgress(
running
? normalizeTitleProgress(data?.training?.progress ?? data?.progress)
: null
)
window.dispatchEvent(
new CustomEvent('app:sse:training', {
detail: data,
})
)
} catch {
// ignore
}
}
const onNotification = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'notification') return
const title = String(data?.title ?? '').trim()
if (!title) return
const message = String(data?.message ?? '').trim() || undefined
const durationRaw = Number(data?.durationMs ?? 0)
const opts = Number.isFinite(durationRaw) && durationRaw > 0
? { durationMs: durationRaw }
: undefined
switch (String(data?.level ?? 'info').trim().toLowerCase()) {
case 'success':
notifyRef.current?.success(title, message, opts)
break
case 'warning':
notifyRef.current?.warning(title, message, opts)
break
case 'error':
notifyRef.current?.error(title, message, opts)
break
default:
notifyRef.current?.info(title, message, opts)
break
}
} catch {
// ignore
}
}
const onGlobalJob = (ev: MessageEvent) => {
onModelJobEventRef.current?.(ev)
}
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
void loadAutostartState().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('job', onGlobalJob as any)
es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
es.addEventListener('taskState', onTaskState as any)
es.addEventListener('training', onTraining as any)
es.addEventListener('analysisProgress', onAnalysisProgress as any)
es.addEventListener('notification', onNotification as any)
const onVis = () => {
if (document.hidden) return
void loadJobs()
void loadTaskStateOnce()
void loadAutostartState().catch(() => {})
if (selectedTabRef.current === 'finished') {
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
stopFallbackPoll()
if (es) {
es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('job', onGlobalJob as any)
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any)
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
es.removeEventListener('notification', onNotification 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,
loadPendingAutoStarts,
applyAutostartState,
loadAutostartState,
bumpAssetForFile,
scheduleDoneRefresh,
])
useEffect(() => {
const desired = new Set<string>()
// 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 {
const host = new URL(norm).hostname.replace(/^www\./i, '').toLowerCase()
return host === 'chaturbate.com' || host.endsWith('.chaturbate.com')
} catch {
return false
}
}
function getCookie(cookiesObj: Record<string, string>, 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<string, string>): 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' })
void loadJobs()
} catch (e: any) {
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
}
}
async function stopAllJobs() {
try {
await apiJSON('/api/record/stop-all', { method: 'POST' })
void loadJobs()
void loadPendingAutoStarts()
void loadAutostartState().catch(() => {})
} catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
}
}
async function removeQueuedPostworkJob(id: string) {
const cleanId = String(id || '').trim()
if (!cleanId) return
const beforeJob =
jobsRef.current.find((j) => String((j as any)?.id ?? '').trim() === cleanId) ??
jobs.find((j) => String((j as any)?.id ?? '').trim() === cleanId) ??
null
const beforeFile = baseName(beforeJob?.output || '')
try {
const data = await apiJSON<{
ok?: boolean
id?: string
file?: string
output?: string
}>(`/api/record/postwork/remove?id=${encodeURIComponent(cleanId)}`, {
method: 'POST',
})
if (data?.ok !== true) {
return
}
const removedId = String(data.id || cleanId).trim()
const removedFile =
baseName(String(data.file || data.output || beforeFile || ''))
delete lastSeenAtByJobIdRef.current[removedId]
setJobs((prev) => {
const next = prev.filter((j) => {
const jid = String((j as any)?.id ?? '').trim()
return jid !== removedId
})
jobsRef.current = next
return next
})
setPlayerJob((prev) => {
if (!prev) return prev
const sameId = String((prev as any)?.id ?? '').trim() === removedId
const sameFile =
removedFile &&
baseName(prev.output || '') === removedFile
return sameId || sameFile ? null : prev
})
setDoneJobs((prev) =>
prev.filter((j) => {
const jid = String((j as any)?.id ?? '').trim()
const file = baseName(j.output || '')
if (jid && jid === removedId) return false
if (removedFile && file === removedFile) return false
return true
})
)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
} catch (e: any) {
notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e))
throw 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<any>).detail || {}
if (d.tab === 'finished') setSelectedTab('finished')
if (d.tab === 'categories') setSelectedTab('categories')
if (d.tab === 'models') setSelectedTab('models')
if (d.tab === 'training') setSelectedTab('training')
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])
const manualStartInFlightRef = useRef(false)
async function onStart() {
if (manualStartInFlightRef.current) return false
manualStartInFlightRef.current = true
setBusy(true)
try {
const ok = await startUrl(sourceUrl, { silent: false, immediate: true })
if (ok) setSourceUrl('')
return ok
} finally {
manualStartInFlightRef.current = false
setBusy(false)
}
}
const handleAddToDownloads = useCallback(
async (job: RecordJob): Promise<boolean> => {
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, immediate: 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<T>(opts: {
kind: FinishedFileActionKind
file: string
run: () => Promise<T>
onSuccess?: (result: T) => void | Promise<void>
onError?: (err: unknown) => void | Promise<void>
}) {
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, kind } })
)
}
try {
const result = await run()
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const, kind } })
)
}
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, kind } })
)
}
await onError?.(e)
if (e && typeof e === 'object') {
try {
Object.defineProperty(e, '__finishedDownloadsNotified', {
value: true,
configurable: true,
})
} catch {
;(e as any).__finishedDownloadsNotified = true
}
}
throw e
}
}
const doneCountRef = useRef(doneCount)
useEffect(() => {
doneCountRef.current = doneCount
}, [doneCount])
const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string; from?: string }> => {
const file = baseName(job.output || '')
if (!file) return
try {
const data = await runFinishedFileAction<{ undoToken?: string; from?: string }>({
kind: 'delete',
file,
run: () =>
apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
),
onSuccess: async () => {
window.setTimeout(() => {
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
donePrefetchRef.current = null
scheduleDoneRefresh(900)
}, 320)
},
onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
},
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
const from = typeof data?.from === 'string' ? data.from : undefined
return undoToken ? { undoToken, from } : {}
} catch (e) {
throw e
}
},
[
runFinishedFileAction,
notify,
scheduleDoneRefresh,
]
)
const handleKeepJob = useCallback(
async (job: RecordJob): Promise<void | { newFile?: string }> => {
const file = baseName(job.output || '')
if (!file) return
try {
const data = await runFinishedFileAction<{ newFile?: string }>({
kind: 'keep',
file,
run: () =>
apiJSON<{ newFile?: string }>(
`/api/record/keep?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
),
onSuccess: async () => {
window.setTimeout(() => {
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
donePrefetchRef.current = null
scheduleDoneRefresh(900)
}, 320)
},
onError: async () => {
notify.error('Keep fehlgeschlagen', file)
},
})
return data
} catch (e) {
throw e
}
},
[runFinishedFileAction, notify, scheduleDoneRefresh]
)
const handleToggleHot = useCallback(
async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
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
})
)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return
}
},
[notify, loadDoneCount]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
// --- flags patch (wie bei dir) ---
async function patchModelFlags(patch: any): Promise<any | null> {
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<Record<string, true>>({})
// ✅ 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)
const mergedUpdated = k
? mergeStoredModel(modelsByKeyRef.current[k], updated)
: updated
if (k) {
setModelsByKey((p) => ({
...p,
[k]: mergeStoredModel(p[k], updated),
}))
}
upsertModelCache(mergedUpdated)
if (sameAsPlayer) setPlayerModel(mergedUpdated)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
)
} 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)
const mergedUpdated = k
? mergeStoredModel(modelsByKeyRef.current[k], updated)
: updated
if (k) {
setModelsByKey((p) => ({
...p,
[k]: mergeStoredModel(p[k], updated),
}))
}
upsertModelCache(mergedUpdated)
if (sameAsPlayer) setPlayerModel(mergedUpdated)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
)
} 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)
const mergedUpdated = k
? mergeStoredModel(modelsByKeyRef.current[k], updated)
: updated
if (k) {
setModelsByKey((p) => ({
...p,
[k]: mergeStoredModel(p[k], updated),
}))
}
upsertModelCache(mergedUpdated)
if (sameAsPlayer) setPlayerModel(mergedUpdated)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
)
} 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
let listenersAttached = false
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) {
void startUrl(url, { silent: false, immediate: true })
}
} catch {
// ignore
} finally {
inFlight = false
}
}
const kick = () => {
void checkClipboard()
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await checkClipboard()
schedule(document.hidden ? 5000 : 1500)
}, ms)
}
const startClipboardPolling = async () => {
const allowed = await canReadClipboardWithoutPrompt()
if (cancelled || !allowed) return
listenersAttached = true
window.addEventListener('focus', kick)
document.addEventListener('visibilitychange', kick)
schedule(0)
}
void startClipboardPolling()
return () => {
cancelled = true
if (timer) window.clearTimeout(timer)
if (listenersAttached) {
window.removeEventListener('focus', kick)
document.removeEventListener('visibilitychange', kick)
}
}
}, [autoAddEnabled, autoStartEnabled, startUrl])
const isTrainingTab = selectedTab === 'training'
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
useEffect(() => {
if (!isTrainingTab) {
setTrainingImageExpanded(false)
}
}, [isTrainingTab])
useEffect(() => {
if (isTrainingTab) {
setTrainingTabMounted(true)
}
}, [isTrainingTab])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
if (!authed) {
return <LoginPage onLoggedIn={checkAuth} />
}
const formatHeaderStatValue = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
if (n >= 10000) return `${Math.round(n / 1000)}k`
if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
return String(Math.max(0, Math.floor(n)))
}
const headerShellClass =
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] max-lg:[@media_(orientation:landscape)]:rounded-xl max-lg:[@media_(orientation:landscape)]:p-2'
const headerStatClass =
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
const mobileHeaderStatClass =
'flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-gray-200/70 bg-white/75 px-1.5 text-[10px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
const headerStatValueClass =
'inline-block min-w-[3ch] text-right tabular-nums text-gray-950 dark:text-white'
const headerActionClass =
'h-9 shrink-0 rounded-xl px-3 text-sm max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:rounded-lg max-lg:[@media_(orientation:landscape)]:px-2.5 max-lg:[@media_(orientation:landscape)]:text-xs'
const headerStatItems = [
{
label: 'Online',
value: onlineModelsCount,
icon: SignalIcon,
},
{
label: 'Watched',
value: onlineWatchedModelsCount,
icon: EyeIcon,
},
{
label: 'Favoriten',
value: onlineFavCount,
icon: StarIcon,
},
{
label: 'Likes',
value: onlineLikedCount,
icon: HeartIcon,
},
]
return (
<div
className={[
'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden',
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
].join(' ')}
>
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
</div>
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
<div className="mx-auto max-w-7xl p-3 max-lg:[@media_(orientation:landscape)]:p-1.5">
<div className={headerShellClass}>
<div className="grid gap-2.5 max-lg:[@media_(orientation:landscape)]:gap-1.5">
<div className="flex flex-col gap-2.5 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-start max-lg:[@media_(orientation:landscape)]:justify-between max-lg:[@media_(orientation:landscape)]:gap-2 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:w-8 max-lg:[@media_(orientation:landscape)]:rounded-lg">
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
</div>
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg max-lg:[@media_(orientation:landscape)]:text-base">
Recorder
</h1>
<div className="hidden min-w-0 flex-wrap items-center gap-1.5 lg:flex">
{headerStatItems.map((item) => {
const Icon = item.icon
return (
<span
key={item.label}
className={headerStatClass}
title={item.label}
>
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
<span className={headerStatValueClass}>
{formatHeaderStatValue(item.value)}
</span>
</span>
)
})}
</div>
</div>
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:hidden max-lg:[@media_(orientation:landscape)]:hidden">
{headerStatItems.map((item) => {
const Icon = item.icon
return (
<span
key={item.label}
className={mobileHeaderStatClass}
title={item.label}
>
<Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden="true" />
<span className={headerStatValueClass}>
{formatHeaderStatValue(item.value)}
</span>
</span>
)
})}
</div>
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px] max-lg:[@media_(orientation:landscape)]:hidden">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
</div>
<div className="flex flex-col gap-2 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:justify-end sm:flex-row sm:items-center lg:justify-end">
{showPerfMon ? (
<PerformanceMonitor
mode="inline"
compact
className="w-full max-lg:[@media_(orientation:landscape)]:w-[300px] sm:w-[300px] lg:w-[360px]"
/>
) : null}
<div className="grid grid-cols-2 gap-1.5 max-lg:[@media_(orientation:landscape)]:flex max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:gap-2 sm:flex sm:items-center sm:gap-2">
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
className={`${headerActionClass} w-full sm:w-auto`}
>
<span className="inline-flex items-center justify-center gap-1.5">
<CircleStackIcon className="h-4 w-4" aria-hidden="true" />
Cookies
</span>
</Button>
<Button
variant="soft"
color="red"
onClick={logout}
className={`${headerActionClass} w-full sm:w-auto`}
>
<span className="inline-flex items-center justify-center gap-1.5">
<ArrowRightOnRectangleIcon className="h-4 w-4" aria-hidden="true" />
Abmelden
</span>
</Button>
</div>
</div>
</div>
<div className="grid min-w-0 gap-2 max-lg:[@media_(orientation:landscape)]:grid-cols-[minmax(0,1fr)_7.5rem] max-lg:[@media_(orientation:landscape)]:items-center lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="relative min-w-0">
<label className="sr-only">Source URL</label>
<TextInput
ref={sourceUrlInputRef}
value={sourceUrl}
size="sm"
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setSourceUrl(e.target.value)
}
selectAllOnFocus
selectAllOnMouseDown
placeholder="Stream-URL einfügen…"
/>
</div>
<Button
variant="primary"
onClick={onStart}
disabled={!canStart || busy}
className="h-9 w-full rounded-xl px-4 sm:w-auto"
>
<span className="inline-flex items-center justify-center gap-1.5">
<PlayIcon className="h-4 w-4" aria-hidden="true" />
{busy ? 'Starte…' : 'Start'}
</span>
</Button>
</div>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 break-words">{error}</div>
<button
type="button"
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
onClick={() => setError(null)}
aria-label="Fehlermeldung schließen"
title="Schließen"
>
</button>
</div>
</div>
) : null}
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
</div>
) : null}
{busy ? (
<ProgressBar label="Starte Download…" indeterminate />
) : null}
<div className="pt-1">
<Tabs
tabs={tabs}
value={selectedTab}
onChange={setSelectedTab}
ariaLabel="Tabs"
variant="barUnderline"
desktopBreakpoint="lg"
/>
</div>
</div>
</div>
</div>
</header>
<main
data-app-scroll-area="true"
className={[
'w-full lg:min-h-0',
isTrainingTab
? 'pb-4 lg:flex-1 lg:overflow-hidden'
: 'lg:flex-1 lg:overflow-y-auto lg:overscroll-contain lg:[scrollbar-gutter:stable_both-edges]',
].join(' ')}
>
<div
className={[
'w-full p-3',
selectedTab === 'finished'
? 'max-w-none px-0'
: isTrainingTab && trainingImageExpanded
? 'max-w-none px-2 sm:px-3 lg:px-4'
: 'mx-auto max-w-7xl',
].join(' ')}
>
{selectedTab === 'running' ? (
<Downloads
jobs={jobs}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
pending={pendingWatchedRooms}
autostartState={autostartState}
onRefreshAutostartState={async (next?: AutostartState) => {
try {
if (next) {
applyAutostartState(next)
return
}
await loadAutostartState()
} catch {
// ignore
}
}}
onOpenPlayer={openPlayer}
onStopJob={stopJob}
onStopAllJobs={stopAllJobs}
onRemoveQueuedPostworkJob={removeQueuedPostworkJob}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onAddToDownloads={handleAddToDownloads}
onRemovePending={handleRemovePendingWatchedRoom}
blurPreviews={Boolean(recSettings.blurPreviews)}
enableConcurrentDownloadsLimit={Boolean(recSettings.enableConcurrentDownloadsLimit)}
maxConcurrentDownloads={Number(recSettings.maxConcurrentDownloads ?? 20)}
/>
) : null}
{selectedTab === 'finished' ? (
<FinishedDownloads
jobs={jobs}
modelsByKey={modelsByKey}
doneJobs={doneJobs}
doneTotal={doneCount}
page={donePage}
pageSize={donePageSize}
onPageSizeChange={(size) => {
setDonePageSize(size)
setDonePage(1)
donePrefetchRef.current = null
}}
onPageChange={setDonePage}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJobWithUndo}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onKeepJob={handleKeepJob}
onSplitJob={openSplitModal}
onAddToDownloads={handleAddToDownloads}
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
assetNonce={assetNonce}
assetNonceByFile={assetNonceByFile}
sortMode={doneSort}
onSortModeChange={(m) => {
setDoneSort(m)
setDonePage(1)
}}
loadMode="paged"
includeKeep={includeKeep}
onIncludeKeepChange={(checked) => {
setIncludeKeep(checked)
setDonePage(1)
donePrefetchRef.current = null
}}
splitProgressByFile={splitProgressByFile}
/>
) : null}
{selectedTab === 'models' ? (
<ModelsTab
onAddToDownloads={handleAddToDownloads}
onModelsCountChange={handleModelsCountChange}
/>
) : null}
{trainingTabMounted ? (
<div
className={isTrainingTab ? undefined : 'hidden'}
aria-hidden={isTrainingTab ? undefined : true}
>
<TrainingTab
active={isTrainingTab}
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
</div>
) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? (
<Settings
onAssetsGenerated={bumpAssets}
/>
) : null}
</div>
</main>
<CookieModal
open={cookieModalOpen}
onClose={() => setCookieModalOpen(false)}
initialCookies={initialCookies}
onApply={(list) => {
const normalized = normalizeCookies(Object.fromEntries(list.map((c) => [c.name, c.value] as const)))
setCookies(normalized)
void apiJSON('/api/cookies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cookies: normalized }),
}).catch(() => {})
}}
/>
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={jobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
// ✅ neu: gleiche Teaser-Settings wie FinishedDownloads
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
onToggleHot={handleToggleHot}
onDelete={handleDeleteJob}
onKeep={handleKeepJob} // ✅ neu
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onStopJob={stopJob}
/>
<VideoSplitModal
key={splitModalKey}
open={splitModalOpen}
job={splitJob}
videoSrc={videoSrcFromJob(splitJob)}
timelinePreviewCount={8}
onClose={() => {
setSplitModalOpen(false)
setSplitJob(null)
}}
onApply={async ({ job, splits, segments, deleteOriginal, onProgress }) => {
const file = baseName(job.output || '')
if (!file) {
throw new Error('Kein Dateiname vorhanden.')
}
const payload =
Array.isArray(segments) && segments.length > 0
? {
file,
segments: segments.map((seg) => ({
start: Number(seg.start),
end: Number(seg.end),
})),
deleteOriginal,
}
: {
file,
splits,
deleteOriginal,
}
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
const totalFallback =
Array.isArray(segments) && segments.length > 0
? segments.length
: Math.max(0, splits.length + 1)
const selectedSegmentForEvent = (ev: any) => {
const pos = Number(ev?.current ?? 0)
if (!Number.isFinite(pos) || pos < 1 || pos > segments.length) {
return null
}
return segments[pos - 1] ?? null
}
type ModalSplitProgress = Parameters<NonNullable<typeof onProgress>>[0]
const publish = (progress: ModalSplitProgress) => {
pushSplitProgress(
file,
progress as Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
)
onProgress?.(progress)
}
return await (async () => {
let buffer = ''
let finalData: any = null
let total = totalFallback
try {
publish({
phase: 'preparing',
total: totalFallback,
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message: 'Split wird vorbereitet…',
})
const res = await fetch('/api/record/split?stream=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
if (!res.body) {
throw new Error('Split-Stream konnte nicht gelesen werden.')
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
const applyEvent = (ev: any) => {
const type = String(ev?.type ?? '').trim()
switch (type) {
case 'start': {
total = Math.max(1, Number(ev?.total ?? totalFallback))
publish({
phase: 'preparing',
total,
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message: String(ev?.message || 'Split gestartet'),
})
break
}
case 'segment_start': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: 'splitting',
total: evTotal,
current,
completed: current - 1,
segmentProgress: 0,
overallProgress: clamp01((current - 1) / evTotal),
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'segment_progress': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const segmentProgress = clamp01(Number(ev?.progress ?? 0))
const overallProgress = clamp01(
Number.isFinite(Number(ev?.overall))
? Number(ev?.overall)
: (current - 1 + segmentProgress) / evTotal
)
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: 'splitting',
total: evTotal,
current,
completed: current - 1,
segmentProgress,
overallProgress,
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'segment_done': {
const current = Math.max(1, Number(ev?.current ?? 1))
const evTotal = Math.max(1, Number(ev?.total ?? total))
const currentSegment = selectedSegmentForEvent(ev)
publish({
phase: current >= evTotal ? 'done' : 'splitting',
total: evTotal,
current,
completed: current,
segmentProgress: 1,
overallProgress: clamp01(current / evTotal),
message: String(ev?.message || `Segment ${current}/${evTotal} fertig`),
segment: currentSegment
? {
index: currentSegment.index,
label: currentSegment.label,
start: currentSegment.start,
end: currentSegment.end,
duration: currentSegment.duration,
}
: {
index: current,
label: `Segment ${current}`,
start: Number(ev?.start ?? 0),
end: Number(ev?.end ?? 0),
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
},
})
break
}
case 'error': {
throw new Error(String(ev?.message || 'Split fehlgeschlagen'))
}
case 'done': {
finalData = ev
publish({
phase: 'done',
total: Math.max(1, Number(ev?.total ?? total)),
current: Math.max(1, Number(ev?.total ?? total)),
completed: Math.max(1, Number(ev?.total ?? total)),
segmentProgress: 1,
overallProgress: 1,
message: String(ev?.message || 'Split abgeschlossen'),
})
break
}
}
}
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line) continue
applyEvent(JSON.parse(line))
}
}
buffer += decoder.decode()
if (buffer.trim()) {
applyEvent(JSON.parse(buffer.trim()))
}
if (!finalData?.ok) {
throw new Error('Split wurde nicht erfolgreich abgeschlossen.')
}
bumpAssets()
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
} catch (err) {
const message =
err instanceof Error ? err.message : 'Split fehlgeschlagen.'
publish({
phase: 'error',
total: Math.max(1, totalFallback),
current: 0,
completed: 0,
segmentProgress: 0,
overallProgress: 0,
message,
})
notify.error('Split fehlgeschlagen', `${file}${message}`)
throw err
}
})()
}}
/>
{playerJob ? (
<Player
key={[
String((playerJob as any)?.id ?? ''),
baseName(playerJob.output || ''),
].join('::')}
job={playerJob}
modelKey={playerModelKey ?? undefined}
modelsByKey={modelsByKey}
expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => {
setPlayerJob(null)
setPlayerStartAtSec(null)
}}
startAtSec={playerStartAtSec ?? undefined}
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
isFavorite={Boolean(playerModel?.favorite)}
isLiked={playerModel?.liked === true}
isWatching={Boolean(playerModel?.watching)}
onKeep={handleKeepJob}
onDelete={handleDeleteJob}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onStopJob={stopJob}
onToggleWatch={handleToggleWatch}
/>
) : null}
</div>
</div>
)
}