nsfwapp/frontend/src/App.tsx
2026-05-11 19:13:54 +02:00

4584 lines
139 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 RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player'
import type { RecordJob } from './types'
import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails'
import {
SignalIcon,
HeartIcon,
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'
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>
}
type RecorderSettingsState = {
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
enableConcurrentDownloadsLimit?: boolean
maxConcurrentDownloads?: number
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
enableNotifications?: boolean
}
type JobEvent = {
type?: 'job_upsert' | 'job_remove'
model?: string
jobId?: string
status?: string
phase?: string
progress?: number
sourceUrl?: string
output?: string
startedAt?: string
startedAtMs?: number
endedAt?: string
endedAtMs?: number
sizeBytes?: number
durationSeconds?: number
previewState?: string
roomStatus?: string
isOnline?: boolean
modelImageUrl?: string
modelChatRoomUrl?: string
ts?: number
error?: string
postWorkKey?: string
postWork?: {
state?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
}
// ✅ neu für finished_postwork
file?: string
assetId?: string
queue?: 'postwork' | 'enrich'
state?: 'queued' | 'running' | 'done' | 'error' | 'missing'
label?: string
position?: number
waiting?: number
running?: number
maxParallel?: number
}
type TaskStateEvent = {
type?: 'task_state'
generateAssetsRunning?: boolean
regenerateAssetsRunning?: boolean
checkVideosRunning?: boolean
cleanupRunning?: boolean
anyRunning?: boolean
ts?: number
}
type AnalysisProgressEvent = {
type?: 'analysis_progress'
scope?: string
requestId?: string
analysisRequestId?: string
running?: boolean
phase?: 'starting' | 'running' | 'done' | 'error' | string
progress?: number
current?: number
total?: number
file?: string
sourceFile?: string
message?: string
error?: string
startedAtMs?: number
finishedAtMs?: number
durationMs?: number
ts?: number
}
type BackgroundSplitProgress = {
phase: 'preparing' | 'splitting' | 'done' | 'error'
total: number
current: number
completed: number
segmentProgress?: number
overallProgress?: number
message?: string
segment?: {
index: number
label: string
start: number
end: number
duration: number
}
startedAtMs: number
sourceFile: string
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
}
type StoredModel = {
id: string
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
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 normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'public'
case 'private':
return 'private'
case 'hidden':
return 'hidden'
case 'away':
return 'away'
case 'offline':
return 'offline'
default:
return 'unknown'
}
}
function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
return normalizePendingShow(v)
}
function roomStatusLabelDE(v: unknown): string {
switch (normalizeRoomStatusText(v)) {
case 'public':
return 'live'
case 'private':
return 'privat'
case 'hidden':
return 'hidden'
case 'away':
return 'away'
case 'offline':
return 'offline'
default:
return 'unbekannt'
}
}
function normalizeHttpUrl(raw: string): string | null {
let v = (raw ?? '').trim()
if (!v) return null
// häufige Copy/Paste-Randzeichen entfernen
v = v.replace(/^[("'[{<]+/, '').replace(/[)"'\]}>.,;:]+$/, '')
// ohne Scheme -> https://
if (!/^https?:\/\//i.test(v)) v = `https://${v}`
try {
const u = new URL(v)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
return u.toString()
} catch {
return null
}
}
function extractFirstUrl(text: string): string | null {
const t = (text ?? '').trim()
if (!t) return null
for (const token of t.split(/\s+/g)) {
const url = normalizeHttpUrl(token)
if (url) return url
}
return null
}
type Provider = 'chaturbate' | 'mfc'
function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
try {
const host = new URL(normUrl).hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'chaturbate.com' || host.endsWith('.chaturbate.com')) return 'chaturbate'
if (host === 'myfreecams.com' || host.endsWith('.myfreecams.com')) return 'mfc'
return null
} catch {
return null
}
}
function chaturbateUserFromUrl(normUrl: string): string {
try {
const u = new URL(normUrl)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host !== 'chaturbate.com' && !host.endsWith('.chaturbate.com')) return ''
// https://chaturbate.com/<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 bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
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[]
ts: number
}
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
return Math.max(1, Math.ceil(safeCount / pageSize))
}
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return
const key = makePrefetchKey(pageToFetch, doneSort)
const cur = donePrefetchRef.current
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
// frisch genug
return
}
donePrefetchInFlightRef.current = true
try {
const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
donePrefetchRef.current = { key, items, ts: Date.now() }
} finally {
donePrefetchInFlightRef.current = false
}
}, [doneSort, includeKeep, donePageSize])
const loadDoneCount = useCallback(async () => {
const now = Date.now()
// ✅ harte Dedupe-Schranke gegen Bursts
if (doneCountInFlightRef.current) return
if (now - doneCountLastAtRef.current < 500) return
doneCountInFlightRef.current = true
doneCountLastAtRef.current = now
try {
const res = await fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const countRaw = Number(data?.count ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setDoneCount(count)
} catch {
// ignore
} finally {
doneCountInFlightRef.current = false
}
}, [includeKeep])
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
try {
const key = makePrefetchKey(pageToLoad, sortToLoad)
const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs(prefetched.items)
donePrefetchRef.current = null
return
}
const res = await fetch(
`/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}&withCount=1${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setDoneJobs(items)
const countRaw = Number(data?.count ?? doneCount)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (count !== doneCount) {
setDoneCount(count)
}
const totalPages = getDoneTotalPages(count, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
} catch {
// ignore
}
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
useEffect(() => {
try {
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
} catch {
// ignore
}
}, [includeKeep])
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])
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
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 overviewInFlightRef = useRef(false)
const overviewLastSigRef = useRef('')
const overviewLastAtRef = useRef(0)
const overviewTimerRef = useRef<number | null>(null)
const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
const uniqKeys = Array.from(
new Set(
keys
.map((x) => String(x ?? '').trim().toLowerCase())
.filter(Boolean)
)
)
const sig = uniqKeys.join('|')
const now = Date.now()
if (overviewInFlightRef.current) return
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return
overviewInFlightRef.current = true
overviewLastSigRef.current = sig
overviewLastAtRef.current = now
try {
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store' as any,
body: JSON.stringify({
keys: uniqKeys,
includeWatched: true,
}),
})
setHeaderStats({
totalCount: Number(data?.totalCount ?? 0),
onlineModelsCount: Number(data?.onlineModelsCount ?? 0),
onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0),
onlineFavCount: Number(data?.onlineFavCount ?? 0),
onlineLikedCount: Number(data?.onlineLikedCount ?? 0),
})
const safeList = Array.isArray(data?.models) ? data.models : []
const built = buildModelsByKey(safeList)
setModelsByKey((prev) => {
const next = { ...prev }
for (const [k, incoming] of Object.entries(built)) {
next[k] = mergeStoredModel(prev[k], incoming)
}
return next
})
} catch {
// ignore
} finally {
overviewInFlightRef.current = false
}
}, [buildModelsByKey])
const maybeNotifyWatchedModelStatusChange = useCallback((
keyLower: string,
nextStatusRaw: unknown,
patch?: {
imageUrl?: string
}
) => {
if (!keyLower) return
if (!recSettingsRef.current.enableNotifications) return
if (!roomStatusBaselineReadyRef.current) return
const model = modelsByKeyRef.current[keyLower]
if (!model?.watching) {
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)
}, 250)
}
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
lastOverviewSigRef.current = requiredModelKeysSig
scheduleOverviewLoad()
}
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()
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()
return
}
scheduleOverviewLoad()
}
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, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
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 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])
// ✅ 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) {
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) {
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
mode: 'probe_retry',
source: 'manual',
})
return true
}
if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && 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') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}, document.hidden ? 60000 : 5000)
}
const loadTaskStateOnce = async () => {
try {
const res = await fetch('/api/tasks/status', { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
setSettingsTaskRunning(
Boolean(
data?.anyRunning ||
data?.generateAssets?.running ||
data?.regenerateAssets?.running ||
data?.cleanup?.running
)
)
} catch {
// ignore
}
}
const onDoneChanged = () => {
if (selectedTabRef.current !== 'finished') return
void loadDonePage(donePageRef.current, doneSortRef.current)
}
const onAutostart = (ev: MessageEvent) => {
try {
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
} catch {}
}
const onFinishedPostwork = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const file = String(msg?.file ?? '').trim()
if (!file) return
const state =
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing'
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork',
state,
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
error: String(msg?.error ?? '').trim() || undefined,
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined,
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(),
},
})
)
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
if (state === 'done' || state === 'error' || state === 'missing') {
bumpAssets()
}
} catch {}
}
const 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 state =
phase === 'error'
? 'error'
: phase === 'done'
? 'done'
: 'running'
const label =
phase === 'error'
? 'Analyse Fehler'
: phase === 'done'
? 'Analyse fertig'
: total > 0
? `Analyse ${percent}%`
: String(msg?.message ?? '').trim() || 'Analyse läuft'
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: 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') {
bumpAssets()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}
} 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
}
}
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('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)
const onVis = () => {
if (document.hidden) return
void loadJobs()
void loadTaskStateOnce()
void loadAutostartState().catch(() => {})
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
stopFallbackPoll()
if (es) {
es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
es.removeEventListener('training', onTraining as any)
es.removeEventListener('analysisProgress', onAnalysisProgress 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,
])
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' })
} catch (e: any) {
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
}
}
async function stopAllJobs() {
try {
await apiJSON('/api/record/stop-all', { method: 'POST' })
} catch (e: any) {
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
}
}
async function removeQueuedPostworkJob(id: string) {
try {
await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, {
method: 'POST',
})
} catch (e: any) {
notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e))
}
}
useEffect(() => {
const onHint = (ev: Event) => {
const e = ev as CustomEvent<{ delta?: number }>
const delta = Number(e.detail?.delta ?? 0)
if (Number.isFinite(delta) && delta !== 0) {
setDoneCount((c) => Math.max(0, c + delta))
}
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [])
useEffect(() => {
const onNav = (ev: Event) => {
const d = (ev as CustomEvent<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 } })
)
}
try {
const result = await run()
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
}
await onSuccess?.(result)
return result
} catch (e) {
if (kind === 'delete' || kind === 'keep') {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
}
await onError?.(e)
throw e
}
}
const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
const removedFile = String(opts?.removedFile ?? '').trim()
setDoneJobs((prev) => {
const filtered = removedFile
? prev.filter((j) => baseName(j.output || '') !== removedFile)
: [...prev]
const need = Math.max(0, donePageSize - filtered.length)
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(
next.map((x) => String(x.id || baseName(x.output || '')).trim())
)
while (next.length < donePageSize && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = {
...buf,
items: buf.items,
ts: buf.ts,
}
return next
})
}, [donePage, donePageSize, doneSort])
const 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(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
const nextDoneCount = Math.max(0, doneCountRef.current - 1)
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1)
}
void loadDoneCount()
}, 320)
},
onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
},
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
const from = typeof data?.from === 'string' ? data.from : undefined
return undoToken ? { undoToken, from } : {}
} catch {
return
}
},
[
runFinishedFileAction,
notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
loadDoneCount,
]
)
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(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
void loadDoneCount()
}, 320)
},
onError: async () => {
notify.error('Keep fehlgeschlagen', file)
},
})
return data
} catch {
return
}
},
[runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount]
)
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
})
)
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return
}
},
[notify]
)
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
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 })
}
} catch {
// ignore
} finally {
inFlight = false
}
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await checkClipboard()
schedule(document.hidden ? 5000 : 1500)
}, ms)
}
const kick = () => {
void checkClipboard()
}
window.addEventListener('focus', kick)
document.addEventListener('visibilitychange', kick)
schedule(0)
return () => {
cancelled = true
if (timer) window.clearTimeout(timer)
window.removeEventListener('focus', kick)
document.removeEventListener('visibilitychange', kick)
}
}, [autoAddEnabled, autoStartEnabled, startUrl])
const isTrainingTab = selectedTab === 'training'
useEffect(() => {
if (!isTrainingTab) {
setTrainingImageExpanded(false)
}
}, [isTrainingTab])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
if (!authed) {
return <LoginPage onLoggedIn={checkAuth} />
}
const headerShellClass =
'rounded-2xl border border-gray-200/70 bg-white/65 p-2.5 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] sm:p-3'
const headerStatClass =
'inline-flex h-8 items-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 headerActionClass =
'h-9 shrink-0 rounded-xl px-3 text-sm'
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={[
isTrainingTab
? 'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden'
: 'min-h-[100dvh]',
'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',
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
].join(' ')}
>
<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 px-4 pb-2.5 pt-2.5 sm:px-6 sm:py-3 lg:px-8">
<div className={headerShellClass}>
<div className="grid gap-2.5">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap 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">
<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">
Recorder
</h1>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{headerStatItems.map((item) => {
const Icon = item.icon
return (
<span
key={item.label}
className={headerStatClass}
>
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
<span className="tabular-nums text-gray-950 dark:text-white">
{item.value}
</span>
</span>
)
})}
</div>
</div>
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px]">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
{showPerfMon ? (
<PerformanceMonitor
mode="inline"
compact
className="w-full sm:w-[300px] lg:w-[360px]"
/>
) : null}
<div className="grid grid-cols-2 gap-1.5 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 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative">
<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"
/>
</div>
</div>
</div>
</div>
</header>
<main
className={[
'mx-auto w-full py-2',
isTrainingTab && trainingImageExpanded
? 'max-w-none px-2 sm:px-3 lg:px-4'
: 'max-w-7xl px-4 sm:px-6 lg:px-8',
isTrainingTab
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
: 'space-y-2',
].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}
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} />
) : null}
{selectedTab === 'training' ? (
<TrainingTab
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? (
<RecorderSettings
onAssetsGenerated={bumpAssets}
/>
) : null}
</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={({ job, splits, segments, deleteOriginal }) => {
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
}
const publish = (
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
) => {
pushSplitProgress(file, progress)
}
void (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()
void loadDonePage(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}`)
}
})()
}}
/>
{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>
)
}