nsfwapp/frontend/src/App.tsx
2026-04-07 12:16:26 +02:00

3711 lines
113 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 } from 'react'
import './App.css'
import Button from './components/ui/Button'
import CookieModal from './components/ui/CookieModal'
import Tabs, { type TabItem } from './components/ui/Tabs'
import RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player'
import type { RecordJob } from './types'
import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails'
import {
SignalIcon,
HeartIcon,
HandThumbUpIcon,
EyeIcon,
ArrowDownTrayIcon,
ArchiveBoxIcon,
UserGroupIcon,
Squares2X2Icon,
Cog6ToothIcon,
} from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal'
import TextInput from './components/ui/TextInput'
import LastUpdatedText from './components/ui/LastUpdatedText'
const COOKIE_STORAGE_KEY = 'record_cookies'
function normalizeCookies(obj: Record<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
cleanupRunning?: boolean
anyRunning?: boolean
ts?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
enableConcurrentDownloadsLimit: false,
maxConcurrentDownloads: 20,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 5,
enableNotifications: true,
}
type StoredModel = {
id: string
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
isUrl?: boolean
path?: string
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
lastOnlineAt?: string
lastOfflineAt?: string
lastRoomSyncAt?: string
}
type PendingWatchedRoom = {
id: string
modelKey: string
url: string
currentShow: string // private/hidden/away/public/unknown
imageUrl?: string
}
type ParsedModel = {
input: string
isUrl: boolean
host?: string
path?: string
modelKey: string
}
type ChaturbateOnlineRoomItem = {
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
type ChaturbateOnlineResponse = {
enabled?: boolean
fetchedAt?: string
count?: number
total?: number
lastError?: string
rooms?: ChaturbateOnlineRoomItem[]
}
type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'public'
case 'private':
return 'private'
case 'hidden':
return 'hidden'
case 'away':
return 'away'
case 'offline':
return 'offline'
default:
return 'unknown'
}
}
function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
return normalizePendingShow(v)
}
function roomStatusLabelDE(v: unknown): string {
switch (normalizeRoomStatusText(v)) {
case 'public':
return 'live'
case 'private':
return 'privat'
case 'hidden':
return 'hidden'
case 'away':
return 'away'
case 'offline':
return 'offline'
default:
return 'unbekannt'
}
}
function normalizeHttpUrl(raw: string): string | null {
let v = (raw ?? '').trim()
if (!v) return null
// häufige Copy/Paste-Randzeichen entfernen
v = v.replace(/^[("'[{<]+/, '').replace(/[)"'\]}>.,;:]+$/, '')
// ohne Scheme -> https://
if (!/^https?:\/\//i.test(v)) v = `https://${v}`
try {
const u = new URL(v)
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
return u.toString()
} catch {
return null
}
}
function extractFirstUrl(text: string): string | null {
const t = (text ?? '').trim()
if (!t) return null
for (const token of t.split(/\s+/g)) {
const url = normalizeHttpUrl(token)
if (url) return url
}
return null
}
type Provider = 'chaturbate' | 'mfc'
function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
try {
const host = new URL(normUrl).hostname.replace(/^www\./i, '').toLowerCase()
if (host === 'chaturbate.com' || host.endsWith('.chaturbate.com')) return 'chaturbate'
if (host === 'myfreecams.com' || host.endsWith('.myfreecams.com')) return 'mfc'
return null
} catch {
return null
}
}
function chaturbateUserFromUrl(normUrl: string): string {
try {
const u = new URL(normUrl)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
if (host !== 'chaturbate.com' && !host.endsWith('.chaturbate.com')) return ''
// https://chaturbate.com/<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 raw = String((job as any)?.sourceUrl ?? '').trim()
const norm0 = normalizeHttpUrl(raw)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
const keyFromUrl = providerKeyLowerFromUrl(norm)
if (keyFromUrl) return keyFromUrl
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
return keyFromFile
}
function modelKeyFromFilename(fileOrPath: string): string | null {
const file = stripHotPrefix(baseName(fileOrPath))
const base = file.replace(/\.[^.]+$/, '') // ext weg
const m = base.match(reModel)
if (m?.[1]?.trim()) return m[1].trim()
const i = base.lastIndexOf('_')
if (i > 0) return base.slice(0, i)
return base ? base : null
}
function modelEventKeyFromJob(job: RecordJob): string {
const raw = String((job as any)?.sourceUrl ?? '').trim()
const norm0 = normalizeHttpUrl(raw)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
const keyFromUrl = providerKeyLowerFromUrl(norm)
if (keyFromUrl) return keyFromUrl
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
return keyFromFile
}
function modelEventKeyFromDoneJob(job: RecordJob): string {
const { modelKey } = modelKeyAndHostFromJob(job)
return String(modelKey || '').trim().toLowerCase()
}
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
let host = ''
try {
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
const u0 = extractFirstUrl(raw)
if (u0) {
const u = new URL(u0)
host = u.hostname.replace(/^www\./i, '').toLowerCase()
}
} catch {
// ignore
}
if (keyFromFile) {
return { modelKey: keyFromFile, host }
}
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
const u0 = extractFirstUrl(raw)
if (u0) {
const norm0 = normalizeHttpUrl(u0)
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
const keyFromUrl = providerKeyLowerFromUrl(norm)
if (keyFromUrl) {
return { modelKey: keyFromUrl, host }
}
}
return { modelKey: '', host }
}
const isTerminalJobStatusForCount = (status?: unknown) => {
const s = String(status ?? '').trim().toLowerCase()
return (
s === 'stopped' ||
s === 'finished' ||
s === 'failed' ||
s === 'done' ||
s === 'completed' ||
s === 'canceled' ||
s === 'cancelled'
)
}
const isPostworkJobForCount = (job: RecordJob): boolean => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
if (phase.toLowerCase() === 'postwork') return true
return false
}
function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
const incomingId = String((incoming as any)?.id ?? '').trim()
if (!incomingId) return list
const byId = new Map<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
})
}
export default function App() {
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)
// ✅ 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 [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>>({})
// "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({})
// optional: URL-Feld leeren
setSourceUrl('')
}
}, [])
useEffect(() => {
void checkAuth()
}, [checkAuth])
const isCookieGateError = (msg: string) => {
const m = (msg || '').toLowerCase()
return (
m.includes('altersverifikationsseite erhalten') ||
m.includes('verify your age') ||
m.includes('schutzseite von cloudflare erhalten') ||
m.includes('just a moment') ||
m.includes('kein room-html')
)
}
const showMissingCookiesMessage = (opts?: { silent?: boolean }) => {
const silent = Boolean(opts?.silent)
const title = 'Cookies fehlen oder sind abgelaufen'
const body =
'Der Recorder hat statt des Room-HTML eine Schutz-/Altersverifikationsseite erhalten. ' +
'Bitte Cookies aktualisieren (bei Chaturbate z.B. cf_clearance + sessionId) und erneut starten.'
// Wenn Nutzer aktiv klickt: oben als Error-Box zeigen + Cookie-Modal anbieten
if (!silent) {
setError(`⚠️ ${title}. ${body}`)
// optional aber hilfreich: Modal direkt öffnen
setCookieModalOpen(true)
return
}
// Bei silent (Auto-Start / Queue): nur selten Toast
const now = Date.now()
if (now - cookieProblemLastAtRef.current > 15_000) {
cookieProblemLastAtRef.current = now
notifyRef.current?.error(title, body)
}
}
useEffect(() => {
notifyRef.current = notify
}, [notify])
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
const [showPerfMon, setShowPerfMon] = useState(false)
useEffect(() => {
const w = window as any
const id =
typeof w.requestIdleCallback === 'function'
? w.requestIdleCallback(() => setShowPerfMon(true), { timeout: 1500 })
: window.setTimeout(() => setShowPerfMon(true), 800)
return () => {
if (typeof w.cancelIdleCallback === 'function') w.cancelIdleCallback(id)
else window.clearTimeout(id)
}
}, [])
const BASE_DONE_PAGE_SIZE = 6
const [donePageSize, setDonePageSize] = useState<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])
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(() => {
try {
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
} catch {}
}, [doneSort])
useEffect(() => {
if (!authed) return
if (selectedTab !== 'finished') return
void loadDonePage(donePage, doneSort)
}, [authed, selectedTab, donePage, doneSort, loadDonePage])
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
const h = String(m?.host ?? '').toLowerCase()
const input = String(m?.input ?? '').toLowerCase()
return h.includes('chaturbate') || input.includes('chaturbate.com')
}, [])
const applyJobDelta = useCallback((msg: JobEvent) => {
const jobId = String(msg?.jobId ?? '').trim()
if (!jobId) return
const now = Date.now()
const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => {
return {
...(prevJob ?? ({} as RecordJob)),
id: jobId,
status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
sourceUrl: msg.sourceUrl ?? ((prevJob as any)?.sourceUrl ?? ''),
output: msg.output ?? (prevJob?.output ?? ''),
startedAt: msg.startedAt ?? (prevJob?.startedAt ?? ''),
endedAt: msg.endedAt ?? (prevJob?.endedAt ?? undefined),
phase: msg.phase ?? (prevJob as any)?.phase,
progress: msg.progress ?? (prevJob as any)?.progress,
startedAtMs: msg.startedAtMs ?? (prevJob as any)?.startedAtMs,
endedAtMs: msg.endedAtMs ?? (prevJob as any)?.endedAtMs,
sizeBytes: msg.sizeBytes ?? (prevJob as any)?.sizeBytes,
durationSeconds: msg.durationSeconds ?? (prevJob as any)?.durationSeconds,
previewState: msg.previewState ?? (prevJob as any)?.previewState,
roomStatus: msg.roomStatus ?? (prevJob as any)?.roomStatus,
isOnline: msg.isOnline ?? (prevJob as any)?.isOnline,
modelImageUrl: msg.modelImageUrl ?? (prevJob as any)?.modelImageUrl,
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prevJob as any)?.modelChatRoomUrl,
postWorkKey: msg.postWorkKey ?? (prevJob as any)?.postWorkKey,
postWork: msg.postWork
? {
...((prevJob as any)?.postWork ?? {}),
...msg.postWork,
}
: (prevJob as any)?.postWork,
} as any
}
if (msg.type === 'job_remove') {
delete lastSeenAtByJobIdRef.current[jobId]
setJobs((prev) => {
const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId)
jobsRef.current = next
return next
})
setPlayerJob((prev) =>
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
)
return
}
if (msg.type !== 'job_upsert') return
lastSeenAtByJobIdRef.current[jobId] = now
setJobs((prev) => {
const sameId = prev.filter((j) => String((j as any).id ?? '').trim() === jobId)
const prevJob = sameId.length > 0 ? sameId[0] : null
const merged = mergeJob(prevJob)
const next = [
merged,
...prev.filter((j) => String((j as any).id ?? '').trim() !== jobId),
].sort((a, b) => {
const aMs =
Number((a as any)?.startedAtMs ?? 0) ||
(a?.startedAt ? Date.parse(a.startedAt) || 0 : 0)
const bMs =
Number((b as any)?.startedAtMs ?? 0) ||
(b?.startedAt ? Date.parse(b.startedAt) || 0 : 0)
return bMs - aMs
})
jobsRef.current = next
return next
})
setPlayerJob((prev) => {
if (!prev) return prev
if (String((prev as any).id ?? '').trim() !== jobId) return prev
return mergeJob(prev)
})
}, [])
const ensureModelEventListener = useCallback((name: string) => {
const es = eventSourceRef.current
const handler = onModelJobEventRef.current
const n = String(name ?? '').trim().toLowerCase()
if (!es || !handler || !n) return
if (modelEventNamesRef.current.has(n)) return
modelEventNamesRef.current.add(n)
es.addEventListener(n, handler as any)
}, [])
const removeModelEventListener = useCallback((name: string) => {
const es = eventSourceRef.current
const handler = onModelJobEventRef.current
const n = String(name ?? '').trim().toLowerCase()
if (!es || !handler || !n) return
if (!modelEventNamesRef.current.has(n)) return
es.removeEventListener(n, handler as any)
modelEventNamesRef.current.delete(n)
}, [])
const isVisibleDownloadJobForSSE = (job: RecordJob): boolean => {
if (isPostworkJobForCount(job)) return false
if (isTerminalJobStatusForCount((job as any)?.status)) return false
if (Boolean((job as any)?.endedAt)) return false
return true
}
const isVisiblePostworkJobForSSE = (job: RecordJob): boolean => {
if (!isPostworkJobForCount(job)) return false
if (isTerminalJobStatusForCount((job as any)?.status)) return false
return true
}
const buildModelsByKey = useCallback((list: StoredModel[]) => {
const map: Record<string, StoredModel> = {}
for (const m of Array.isArray(list) ? list : []) {
const k = (m?.modelKey || '').trim().toLowerCase()
if (!k) continue
const score = (x: StoredModel) => (x.favorite ? 4 : 0) + (x.liked === true ? 2 : 0) + (x.watching ? 1 : 0)
const cur = map[k]
if (!cur || score(m) >= score(cur)) map[k] = m
}
return map
}, [])
const applyRoomStateToModel = useCallback((
keyLower: string,
patch: {
roomStatus?: string
isOnline?: boolean
imageUrl?: string
chatRoomUrl?: string
}
) => {
if (!keyLower) return
setModelsByKey((prev) => {
const cur = prev[keyLower]
if (!cur) return prev
const next: StoredModel = {
...cur,
roomStatus:
patch.roomStatus != null
? String(patch.roomStatus).trim().toLowerCase()
: cur.roomStatus,
isOnline:
patch.isOnline != null
? Boolean(patch.isOnline)
: cur.isOnline,
imageUrl:
patch.imageUrl && String(patch.imageUrl).trim()
? String(patch.imageUrl).trim()
: cur.imageUrl,
chatRoomUrl:
patch.chatRoomUrl && String(patch.chatRoomUrl).trim()
? String(patch.chatRoomUrl).trim()
: cur.chatRoomUrl,
}
return { ...prev, [keyLower]: next }
})
setPendingWatchedRooms((prev) => {
const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
if (idx < 0) return prev
const copy = [...prev]
copy[idx] = {
...copy[idx],
currentShow: normalizePendingShow(patch.roomStatus),
imageUrl:
patch.imageUrl && String(patch.imageUrl).trim()
? String(patch.imageUrl).trim()
: copy[idx].imageUrl,
}
return copy
})
}, [])
type AppModelsSnapshot = {
totalCount: number
onlineModelsCount: number
onlineWatchedModelsCount: number
onlineFavCount: number
onlineLikedCount: number
models: StoredModel[]
}
const [headerStats, setHeaderStats] = useState({
totalCount: 0,
onlineModelsCount: 0,
onlineWatchedModelsCount: 0,
onlineFavCount: 0,
onlineLikedCount: 0,
})
useEffect(() => {
setModelsCount(headerStats.totalCount)
}, [headerStats.totalCount])
const overviewInFlightRef = useRef(false)
const overviewLastSigRef = useRef('')
const overviewLastAtRef = useRef(0)
const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
const uniqKeys = Array.from(
new Set(
keys
.map((x) => String(x ?? '').trim().toLowerCase())
.filter(Boolean)
)
)
const sig = uniqKeys.join('|')
const now = Date.now()
if (overviewInFlightRef.current) return
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return
overviewInFlightRef.current = true
overviewLastSigRef.current = sig
overviewLastAtRef.current = now
try {
const data = await apiJSON<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 : []
setModelsByKey(buildModelsByKey(safeList))
} catch {
// ignore
} finally {
overviewInFlightRef.current = false
}
}, [buildModelsByKey])
const maybeNotifyWatchedModelStatusChange = useCallback((
keyLower: string,
nextStatusRaw: unknown,
patch?: {
imageUrl?: string
}
) => {
if (!keyLower) return
if (!recSettingsRef.current.enableNotifications) return
if (!roomStatusBaselineReadyRef.current) return
const model = modelsByKeyRef.current[keyLower]
if (!model?.watching) {
lastKnownRoomStatusByKeyRef.current[keyLower] = normalizeRoomStatusText(nextStatusRaw)
return
}
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
// keine Änderung
if (prevStatus === nextStatus) return
// keine "erste Sichtung" als Wechsel melden
const hadPreviousRealStatus =
Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) &&
prevStatus !== 'unknown'
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
if (!hadPreviousRealStatus) return
const displayName =
String(model?.modelKey ?? keyLower).trim() || keyLower
const imageUrl =
String(patch?.imageUrl ?? '').trim() ||
String(model?.imageUrl ?? '').trim() ||
undefined
const title =
nextStatus === 'public'
? `${displayName} ist live`
: `${displayName} Status geändert`
const message = `Status: ${roomStatusLabelDE(prevStatus)}${roomStatusLabelDE(nextStatus)}`
notifyRef.current?.info(title, message, {
imageUrl,
imageAlt: displayName,
durationMs: 5000,
onClick: () => {
window.dispatchEvent(
new CustomEvent('open-model-details', {
detail: { modelKey: keyLower },
})
)
},
})
}, [])
const enqueueStart = useCallback(
(item: StartQueueItem) => {
const norm0 = normalizeHttpUrl(item.url)
if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0)
// dedupe: gleiche URL nicht 100x in die Queue
if (startQueuedSetRef.current.has(norm)) return true
startQueuedSetRef.current.add(norm)
startQueueRef.current.push({ ...item, url: norm })
// pump einmal pro Tick schedulen
if (!pumpStartQueueScheduledRef.current) {
pumpStartQueueScheduledRef.current = true
queueMicrotask(() => {
pumpStartQueueScheduledRef.current = false
void pumpStartQueue()
})
}
return true
},
// pumpStartQueue kommt gleich darunter (useCallback), daher eslint ggf. meckert -> ok, wir definieren pumpStartQueue als function declaration unten
[]
)
useEffect(() => {
onModelJobEventRef.current = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
if (msg?.type === 'job_upsert') {
if (modelKey) {
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
if (roomStatus) {
const isOnline =
typeof msg?.isOnline === 'boolean'
? Boolean(msg.isOnline)
: roomStatus !== 'offline'
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
})
applyRoomStateToModel(modelKey, {
roomStatus,
isOnline,
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(),
})
const pendingUrl = String((pendingAutoStartByKeyRef.current || {})[modelKey] ?? '').trim()
if (roomStatus === 'public' && pendingUrl && !busyRef.current) {
enqueueStart({
url: pendingUrl,
silent: true,
pendingKeyLower: modelKey,
})
}
}
}
}
applyJobDelta(msg)
} catch {
// ignore
}
}
}, [applyJobDelta, applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange])
useEffect(() => {
const onOpen = (ev: Event) => {
const e = ev as CustomEvent<{ modelKey?: string }>
const raw0 = (e.detail?.modelKey ?? '').trim()
if (!raw0) return
// 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen
// Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key
const looksLikeKey =
!raw0.includes(' ') &&
!raw0.includes('/') &&
!raw0.includes('\\')
if (looksLikeKey) {
const k = raw0.replace(/^@/, '').trim().toLowerCase()
if (k) setDetailsModelKey(k)
return
}
// 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren
const norm0 = normalizeHttpUrl(raw0)
if (!norm0) {
// Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist)
let k = raw0.replace(/^https?:\/\//i, '')
if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k
if (k.includes(':')) k = k.split(':').pop() || k
k = k.trim().toLowerCase()
if (k) setDetailsModelKey(k)
return
}
const norm = canonicalizeProviderUrl(norm0)
const keyLower = providerKeyLowerFromUrl(norm)
if (keyLower) setDetailsModelKey(keyLower)
}
window.addEventListener('open-model-details', onOpen as any)
return () => window.removeEventListener('open-model-details', onOpen as any)
}, [])
useEffect(() => {
const onOpen = (ev: Event) => {
const e = ev as CustomEvent<{ jobId?: string; output?: string }>
const jobId = String(e.detail?.jobId ?? '').trim()
const output = String(e.detail?.output ?? '').trim()
let hit: RecordJob | null = null
if (jobId) {
hit =
jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
null
}
if (!hit && output) {
const wanted = baseName(output)
hit =
jobs.find((j) => baseName(j.output || '') === wanted) ??
doneJobs.find((j) => baseName(j.output || '') === wanted) ??
null
}
if (hit) {
setSplitJob(hit)
setSplitModalKey((k) => k + 1)
setSplitModalOpen(true)
}
}
window.addEventListener('open-video-splitter', onOpen as EventListener)
return () => window.removeEventListener('open-video-splitter', onOpen as EventListener)
}, [jobs, doneJobs])
const upsertModelCache = useCallback((m: StoredModel) => {
const now = Date.now()
const cur = modelsCacheRef.current
if (!cur) {
modelsCacheRef.current = { ts: now, list: [m] }
return
}
cur.ts = now
const idx = cur.list.findIndex((x) => x.id === m.id)
if (idx >= 0) cur.list[idx] = m
else cur.list.unshift(m)
}, [])
const recSettingsRef = useRef(recSettings)
useEffect(() => {
recSettingsRef.current = recSettings
}, [recSettings])
const [autostartState, setAutostartState] = useState<AutostartState>({
paused: false,
pausedByUser: false,
pausedByDisk: false,
})
useEffect(() => {
busyRef.current = busy
}, [busy])
useEffect(() => {
cookiesRef.current = cookies
}, [cookies])
useEffect(() => {
jobsRef.current = jobs
}, [jobs])
// pending start falls gerade busy
const lastClipboardUrlRef = useRef<string>('')
// --- START QUEUE (parallel) ---
const START_CONCURRENCY = 4 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start
type StartQueueItem = {
url: string
silent: boolean
pendingKeyLower?: string // wenn aus pendingAutoStartByKey kommt
}
const startQueueRef = useRef<StartQueueItem[]>([])
const startInFlightRef = useRef(0)
const startQueuedSetRef = useRef<Set<string>>(new Set()) // dedupe: verhindert Duplikate
const pumpStartQueueScheduledRef = useRef(false)
const setBusyFromStarts = useCallback(() => {
const v = startInFlightRef.current > 0
setBusy(v)
busyRef.current = v
}, [])
async function doStartNow(normUrl: string, silent: boolean): Promise<boolean> {
normUrl = canonicalizeProviderUrl(normUrl)
// ✅ Duplicate-running guard (wie vorher)
const alreadyRunning = jobsRef.current.some((j) => {
if (String(j.status || '').toLowerCase() !== 'running') return false
if ((j as any).endedAt) return false
const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
return jNorm === normUrl
})
if (alreadyRunning) return true
try {
const currentCookies = cookiesRef.current
const provider = getProviderFromNormalizedUrl(normUrl)
if (!provider) {
if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.')
return false
}
if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
return false
}
const cookieString = Object.entries(currentCookies)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
const created = await apiJSON<RecordJob>('/api/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: normUrl, cookie: cookieString }),
})
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
// UI sofort aktualisieren (optional)
setJobs((prev) => {
const next = upsertJobById(prev, created)
jobsRef.current = next
return next
})
return true
} catch (e: any) {
const msg = e?.message ?? String(e)
// ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
if (isCookieGateError(msg)) {
showMissingCookiesMessage({ silent })
return false
}
if (!silent) setError(msg)
return false
}
}
async function pumpStartQueue(): Promise<void> {
// so viele wie möglich parallel starten
while (startInFlightRef.current < START_CONCURRENCY && startQueueRef.current.length > 0) {
const next = startQueueRef.current.shift()!
startInFlightRef.current++
setBusyFromStarts()
void (async () => {
try {
const ok = await doStartNow(next.url, next.silent)
// wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
if (ok && next.pendingKeyLower) {
removePendingAutoStart(next.pendingKeyLower)
}
} finally {
// dedupe wieder freigeben
startQueuedSetRef.current.delete(next.url)
startInFlightRef.current = Math.max(0, startInFlightRef.current - 1)
setBusyFromStarts()
// falls noch was da ist: weiterpumpen
if (startQueueRef.current.length > 0) {
void pumpStartQueue()
}
}
})()
}
}
const chaturbateStoreKeysLower = useMemo(() => {
const set = new Set<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])
const requiredModelKeys = useMemo(() => {
const set = new Set<string>()
for (const j of jobs) {
const k = modelEventKeyFromJob(j)
if (k) set.add(k)
}
for (const j of doneJobs) {
const k = modelEventKeyFromDoneJob(j)
if (k) set.add(k)
}
for (const p of pendingWatchedRooms) {
const k = String(p?.modelKey ?? '').trim().toLowerCase()
if (k) set.add(k)
}
if (playerJob) {
const k = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
if (k) set.add(k)
}
if (detailsModelKey) {
const k = String(detailsModelKey ?? '').trim().toLowerCase()
if (k) set.add(k)
}
return Array.from(set).sort()
}, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey])
const requiredModelKeysSig = useMemo(
() => requiredModelKeys.join('|'),
[requiredModelKeys]
)
const lastOverviewSigRef = useRef<string>('')
useEffect(() => {
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
lastOverviewSigRef.current = requiredModelKeysSig
void loadAppModelsSnapshot(requiredModelKeys)
}
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()
if (k) {
setModelsByKey((prev) => ({ ...prev, [k]: updated }))
}
try {
upsertModelCache(updated)
} catch {}
setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))
return
}
if (detail?.removed) {
const removedId = String(detail?.id ?? '').trim()
const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim()
if (removedKey) {
setModelsByKey((prev) => {
const { [removedKey]: _drop, ...rest } = prev
return rest
})
}
if (removedId) {
setPlayerModel((prev) => (prev?.id === removedId ? null : prev))
}
return
}
void loadAppModelsSnapshot(requiredModelKeys)
}
window.addEventListener('models-changed', onChanged as any)
return () => window.removeEventListener('models-changed', onChanged as any)
}, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => {
if (!keyLower) return
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}), [keyLower]: url }
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) => {
const nextItem: PendingWatchedRoom = {
id: keyLower,
modelKey: keyLower,
url,
currentShow: normalizePendingShow(show),
imageUrl: imageUrl || undefined,
}
const idx = prev.findIndex(
(x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower
)
if (idx >= 0) {
const copy = [...prev]
copy[idx] = { ...copy[idx], ...nextItem }
return copy
}
return [nextItem, ...prev]
})
}, [])
const removePendingAutoStart = useCallback((keyLower: string) => {
if (!keyLower) return
setPendingAutoStartByKey((prev) => {
const next = { ...(prev || {}) }
delete next[keyLower]
pendingAutoStartByKeyRef.current = next
return next
})
setPendingWatchedRooms((prev) =>
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower)
)
}, [])
const selectedTabRef = useRef(selectedTab)
const donePageRef = useRef(donePage)
const doneSortRef = useRef(doneSort)
useEffect(() => {
donePageRef.current = donePage
}, [donePage])
useEffect(() => {
doneSortRef.current = doneSort
}, [doneSort])
const applyPendingRoomSnapshot = useCallback(
(
keyLower: string,
snap: {
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
chatRoomUrl?: string
}
) => {
if (!keyLower) return
setModelsByKey((prev) => {
const cur = prev[keyLower]
if (!cur) return prev
return {
...prev,
[keyLower]: {
...cur,
roomStatus: snap.show,
isOnline: snap.show !== 'offline',
imageUrl: snap.imageUrl || cur.imageUrl,
chatRoomUrl: snap.chatRoomUrl || cur.chatRoomUrl,
},
}
})
setPendingWatchedRooms((prev) => {
const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
if (idx < 0) return prev
const copy = [...prev]
copy[idx] = {
...copy[idx],
currentShow: snap.show,
imageUrl: snap.imageUrl || copy[idx].imageUrl,
}
return copy
})
},
[]
)
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
const modelsByKeyRef = useRef(modelsByKey)
useEffect(() => {
modelsByKeyRef.current = modelsByKey
}, [modelsByKey])
useEffect(() => {
const next: Record<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
}
const next: PendingWatchedRoom[] = keys.map((rawKey) => {
const key = String(rawKey ?? '').trim().toLowerCase()
const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim()
const model = (modelsByKey as any)?.[key] ?? null
return {
id: key,
modelKey: key,
url,
currentShow: normalizePendingShow(model?.roomStatus),
imageUrl: String(model?.imageUrl ?? '').trim() || undefined,
}
})
setPendingWatchedRooms(next)
}, [pendingAutoStartByKey, modelsByKey])
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setCbOnlineFetchedAt('')
return
}
let cancelled = false
let timer: number | null = null
let inFlight = false
const tick = async () => {
if (cancelled || inFlight) return
const pendingMap = pendingAutoStartByKeyRef.current || {}
const entries = Object.entries(pendingMap)
if (entries.length === 0) return
if (busyRef.current) return
const keys = entries
.map(([rawKey]) => String(rawKey ?? '').trim().toLowerCase())
.filter(Boolean)
if (keys.length === 0) return
inFlight = true
try {
const res = await fetch('/api/chaturbate/online', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
q: keys,
}),
})
const byKey: Record<
string,
{
show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
imageUrl?: string
chatRoomUrl?: string
}
> = {}
if (res.ok) {
const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
for (const room of rooms) {
const key = String(room?.username ?? '').trim().toLowerCase()
if (!key) continue
byKey[key] = {
show: normalizePendingShow(room?.current_show),
imageUrl: String(room?.image_url ?? '').trim() || undefined,
chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
}
}
}
for (const key of keys) {
const snap =
byKey[key] ??
{
show: 'unknown' as const,
imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
}
maybeNotifyWatchedModelStatusChange(key, snap.show, {
imageUrl: snap.imageUrl,
})
applyPendingRoomSnapshot(key, snap)
const url = String((pendingMap as any)?.[key] ?? '').trim()
if (!url) continue
if (snap.show === 'public') {
enqueueStart({
url,
silent: true,
pendingKeyLower: key,
})
continue
}
if (snap.show === 'offline') {
removePendingAutoStart(key)
continue
}
// private / hidden / away / unknown bleiben in der Warteschlange
}
} catch {
// ignore
} finally {
inFlight = false
}
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await tick()
schedule(document.hidden ? 8000 : 3000)
}, ms)
}
schedule(0)
return () => {
cancelled = true
if (timer != null) window.clearTimeout(timer)
}
}, [
recSettings.useChaturbateApi,
applyPendingRoomSnapshot,
enqueueStart,
maybeNotifyWatchedModelStatusChange,
removePendingAutoStart,
])
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
useEffect(() => {
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
}, [chaturbateStoreKeysLower])
useEffect(() => {
selectedTabRef.current = selectedTab
}, [selectedTab])
useEffect(() => {
if (!recSettings.useChaturbateApi) {
setRoomStatusByModelKey({})
return
}
const next: Record<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('')
return
}
let cancelled = false
let timer: number | null = null
const tick = async () => {
try {
const onlineRes = await fetch('/api/chaturbate/online', {
method: 'GET',
cache: 'no-store',
})
if (!onlineRes.ok) return
const data = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
if (cancelled || !data) return
const fetchedAt = String(data.fetchedAt ?? '').trim()
if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') {
setCbOnlineFetchedAt(fetchedAt)
}
} catch {
// ignore
}
}
void tick()
timer = window.setInterval(() => {
void tick()
}, 10_000)
return () => {
cancelled = true
if (timer != null) window.clearInterval(timer)
}
}, [authed, recSettings.useChaturbateApi])
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
const norm0 = normalizeHttpUrl(rawUrl)
if (!norm0) return false
const norm = canonicalizeProviderUrl(norm0)
const silent = Boolean(opts?.silent)
if (!silent) setError(null)
const provider = getProviderFromNormalizedUrl(norm)
if (!provider) {
if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.')
return false
}
const currentCookies = cookiesRef.current
if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
return false
}
// Duplicate-running guard (normalisiert vergleichen)
const alreadyRunning = jobsRef.current.some((j) => {
if (String(j.status || '').toLowerCase() !== 'running') return false
// ✅ Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
if ((j as any).endedAt) return false
const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
return jNorm === norm
})
if (alreadyRunning) return true
// ✅ Nur Auto-/Silent-Starts: Chaturbate ggf. in Warteschlange statt Sofortstart
if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
try {
const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: norm }),
})
const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
if (mkLower) {
let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
let resolvedImageUrl: string | undefined
let resolvedChatRoomUrl: string | undefined
try {
const onlineRes = await fetch('/api/chaturbate/online', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ q: [mkLower] }),
})
if (onlineRes.ok) {
const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
const room = Array.isArray(onlineData?.rooms)
? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
: null
if (room) {
resolvedShow = normalizePendingShow(room.current_show)
resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
}
}
} catch {
// best effort
}
applyPendingRoomSnapshot(mkLower, {
show: resolvedShow,
imageUrl: resolvedImageUrl,
chatRoomUrl: resolvedChatRoomUrl,
})
if (
resolvedShow === 'private' ||
resolvedShow === 'hidden' ||
resolvedShow === 'away' ||
resolvedShow === 'unknown'
) {
queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl)
return true
}
if (resolvedShow === 'offline') {
removePendingAutoStart(mkLower)
return false
}
// public => unten normal starten
}
} catch {
const mkLower = providerKeyLowerFromUrl(norm)
if (mkLower) {
queuePendingAutoStart(mkLower, norm, 'unknown')
return true
}
}
}
try {
const cookieString = Object.entries(currentCookies)
.map(([k, v]) => `${k}=${v}`)
.join('; ')
const created = await apiJSON<RecordJob>('/api/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: norm, cookie: cookieString }),
})
// ✅ verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
// und kurz danach kommt der Job nochmal über SSE/polling rein.
if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true
setJobs((prev) => {
const next = upsertJobById(prev, created)
jobsRef.current = next
return next
})
return true
} catch (e: any) {
const msg = e?.message ?? String(e)
// ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
if (isCookieGateError(msg)) {
showMissingCookiesMessage({ silent })
return false
}
if (!silent) setError(msg)
return false
}
}, [])
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const s = await apiJSON<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(() => {
const activeDownloads = jobs.filter((j) => {
if (isPostworkJobForCount(j)) return false
if (isTerminalJobStatusForCount((j as any)?.status)) return false
if (Boolean((j as any)?.endedAt)) return false
return true
}).length
const activePostwork = jobs.filter((j) => {
if (!isPostworkJobForCount(j)) return false
if (isTerminalJobStatusForCount((j as any)?.status)) return false
return true
}).length
const pendingCount = pendingWatchedRooms.length
return activeDownloads + activePostwork + pendingCount
}, [jobs, pendingWatchedRooms])
const tabs: TabItem[] = [
{
id: 'running',
label: 'Laufende Downloads',
count: runningTabCount,
icon: ArrowDownTrayIcon,
},
{
id: 'finished',
label: 'Abgeschlossene Downloads',
count: doneCount,
icon: ArchiveBoxIcon,
},
{
id: 'models',
label: 'Models',
count: modelsCount,
icon: UserGroupIcon,
},
{
id: 'categories',
label: 'Kategorien',
icon: Squares2X2Icon,
},
{
id: 'settings',
label: 'Einstellungen',
icon: Cog6ToothIcon,
spinIcon: settingsTaskRunning,
},
]
const canStart = useMemo(() => sourceUrl.trim().length > 0, [sourceUrl])
// Cookies load/save
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const res = await apiJSON<{ cookies?: Record<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 applyAutostartState = (data: unknown) => {
const d = (data ?? {}) as any
setAutostartState({
paused: Boolean(d?.paused),
pausedByUser: Boolean(d?.pausedByUser),
pausedByDisk: Boolean(d?.pausedByDisk),
})
}
const onDoneChanged = () => {
if (selectedTabRef.current !== 'finished') return
void loadDonePage(donePageRef.current, doneSortRef.current)
}
const onAutostart = (ev: MessageEvent) => {
try {
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
} catch {}
}
const onFinishedPostwork = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
const file = String(msg?.file ?? '').trim()
if (!file) return
const state =
msg?.state === 'queued' ||
msg?.state === 'running' ||
msg?.state === 'done' ||
msg?.state === 'error' ||
msg?.state === 'missing'
? msg.state
: 'missing'
window.dispatchEvent(
new CustomEvent('finished-downloads:postwork', {
detail: {
file,
assetId: String(msg?.assetId ?? '').trim() || undefined,
queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork',
state,
phase: String(msg?.phase ?? '').trim() || undefined,
label: String(msg?.label ?? '').trim() || undefined,
error: String(msg?.error ?? '').trim() || undefined,
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined,
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(),
},
})
)
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
if (state === 'done' || state === 'error' || state === 'missing') {
bumpAssets()
}
} catch {}
}
const onTaskState = (ev: MessageEvent) => {
try {
const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
setSettingsTaskRunning(
Boolean(
msg?.anyRunning ||
msg?.generateAssetsRunning ||
msg?.regenerateAssetsRunning ||
msg?.cleanupRunning
)
)
} catch {}
}
void loadJobs()
void loadDoneCount()
void loadTaskStateOnce()
void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
.then((s) => {
setAutostartState({
paused: Boolean(s?.paused),
pausedByUser: Boolean(s?.pausedByUser),
pausedByDisk: Boolean(s?.pausedByDisk),
})
})
.catch(() => {})
es = new EventSource('/api/events/stream')
eventSourceRef.current = es
modelEventNamesRef.current = new Set()
es.onopen = () => {
stopFallbackPoll()
void loadTaskStateOnce()
}
es.onerror = () => {
startFallbackPoll()
}
es.addEventListener('doneChanged', onDoneChanged as any)
es.addEventListener('autostart', onAutostart as any)
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
es.addEventListener('taskState', onTaskState as any)
const onVis = () => {
if (document.hidden) return
void loadJobs()
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
stopFallbackPoll()
if (es) {
es.removeEventListener('doneChanged', onDoneChanged as any)
es.removeEventListener('autostart', onAutostart as any)
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
es.removeEventListener('taskState', onTaskState as any)
const handler = onModelJobEventRef.current
if (handler) {
for (const name of modelEventNamesRef.current) {
es.removeEventListener(name, handler as any)
}
}
es.close()
}
eventSourceRef.current = null
modelEventNamesRef.current = new Set()
}
}, [authed, loadJobs, loadDoneCount])
useEffect(() => {
const desired = new Set<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 {
return new URL(norm).hostname.includes('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 === 'running') setSelectedTab('running')
if (d.tab === 'settings') setSelectedTab('settings')
}
window.addEventListener('app:navigate-tab', onNav as EventListener)
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
}, [])
// ---- Player model sync (wie bei dir) ----
useEffect(() => {
if (!playerJob) {
setPlayerModel(null)
setPlayerModelKey(null)
return
}
const keyFromFile = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
setPlayerModelKey(keyFromFile || null)
const hit = keyFromFile ? modelsByKey[keyFromFile] : undefined
setPlayerModel(hit ?? null)
}, [playerJob, modelsByKey])
async function onStart() {
const ok = await startUrl(sourceUrl, { silent: false })
if (ok) {
setSourceUrl('')
}
return ok
}
const handleAddToDownloads = useCallback(
async (job: RecordJob): Promise<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 })
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 handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '')
if (!file) return
try {
const data = await runFinishedFileAction<{ undoToken?: string }>({
kind: 'delete',
file,
run: () =>
apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
),
onSuccess: async () => {
window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setDoneCount((c) => Math.max(0, c - 1))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1)
}
void loadDoneCount()
}, 320)
},
onError: async () => {
notify.error('Löschen fehlgeschlagen', file)
},
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {}
} catch {
return
}
},
[
runFinishedFileAction,
notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
doneCount,
loadDoneCount,
]
)
const handleKeepJob = useCallback(
async (job: RecordJob): Promise<void> => {
const file = baseName(job.output || '')
if (!file) return
try {
await runFinishedFileAction({
kind: 'keep',
file,
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => {
window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
setDoneCount((c) => Math.max(0, c - 1))
const nextDoneCount = Math.max(0, doneCount - 1)
const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterKeep) {
void prefetchDonePage(donePage + 1)
}
void loadDoneCount()
}, 320)
},
onError: async () => {
notify.error('Keep fehlgeschlagen', file)
},
})
} catch {
return
}
},
[
runFinishedFileAction,
notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
loadDoneCount,
doneCount,
]
)
const handleToggleHot = useCallback(
async (job: RecordJob): Promise<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)
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
upsertModelCache(updated)
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
} catch (e: any) {
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
)
// ✅ handleToggleLike (komplett)
const handleToggleLike = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
const { modelKey, host } = modelKeyAndHostFromJob(job)
if (!modelKey) {
notify.error('Like umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
return
}
const guardKey = modelKey
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev =
modelsByKey[modelKey] ??
({
id: '',
input: '',
host: host || undefined,
modelKey,
watching: false,
favorite: false,
liked: null,
isUrl: false,
} as StoredModel)
const nextLiked = !(prev.liked === true)
const optimistic: StoredModel = {
...prev,
host: prev.host || host || undefined,
modelKey,
liked: nextLiked,
favorite: nextLiked ? false : prev.favorite,
}
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
...(prev.id ? { id: prev.id } : {}),
host: optimistic.host || '',
modelKey,
liked: nextLiked,
...(nextLiked ? { favorite: false } : {}),
})
if (!updated) {
setModelsByKey((p) => {
const { [modelKey]: _drop, ...rest } = p
return rest
})
if (prev.id) removeModelCache(prev.id)
if (sameAsPlayer) setPlayerModel(null)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
)
return
}
const k = lower(updated.modelKey || modelKey)
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
upsertModelCache(updated)
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
} catch (e: any) {
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
)
// ✅ handleToggleWatch (komplett)
const handleToggleWatch = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
const { modelKey, host } = modelKeyAndHostFromJob(job)
if (!modelKey) {
notify.error('Watched umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
return
}
const guardKey = modelKey
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev =
modelsByKey[modelKey] ??
({
id: '',
input: '',
host: host || undefined,
modelKey,
watching: false,
favorite: false,
liked: null,
isUrl: false,
} as StoredModel)
const nextWatching = !Boolean(prev.watching)
const optimistic: StoredModel = {
...prev,
host: prev.host || host || undefined,
modelKey,
watching: nextWatching,
}
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
...(prev.id ? { id: prev.id } : {}),
host: optimistic.host || '',
modelKey,
watched: nextWatching,
})
if (!updated) {
setModelsByKey((p) => {
const { [modelKey]: _drop, ...rest } = p
return rest
})
if (prev.id) removeModelCache(prev.id)
if (sameAsPlayer) setPlayerModel(null)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
)
return
}
const k = lower(updated.modelKey || modelKey)
if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
upsertModelCache(updated)
if (sameAsPlayer) setPlayerModel(updated)
window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
} catch (e: any) {
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
)
// Clipboard auto add/start (wie bei dir)
useEffect(() => {
if (!autoAddEnabled && !autoStartEnabled) return
if (!navigator.clipboard?.readText) return
let cancelled = false
let inFlight = false
let timer: number | null = null
const checkClipboard = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const text = await navigator.clipboard.readText()
const url0 = extractFirstUrl(text)
if (!url0) return
const norm0 = normalizeHttpUrl(url0)
if (!norm0) return
const provider = getProviderFromNormalizedUrl(norm0)
if (!provider) return
const url = canonicalizeProviderUrl(norm0)
if (url === lastClipboardUrlRef.current) return
lastClipboardUrlRef.current = url
if (autoAddEnabled) setSourceUrl(url)
if (autoStartEnabled) {
// ✅ immer enqueue (dedupe verhindert doppelt)
enqueueStart({ url, silent: false })
}
} catch {
// ignore
} finally {
inFlight = false
}
}
const schedule = (ms: number) => {
if (cancelled) return
timer = window.setTimeout(async () => {
await checkClipboard()
schedule(document.hidden ? 5000 : 1500)
}, ms)
}
const kick = () => {
void checkClipboard()
}
window.addEventListener('focus', kick)
document.addEventListener('visibilitychange', kick)
schedule(0)
return () => {
cancelled = true
if (timer) window.clearTimeout(timer)
window.removeEventListener('focus', kick)
document.removeEventListener('visibilitychange', kick)
}
}, [autoAddEnabled, autoStartEnabled, enqueueStart])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
if (!authed) {
return <LoginPage onLoggedIn={checkAuth} />
}
return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<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">
<header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
<div className="min-w-0">
<div className="min-w-0">
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
Recorder
</h1>
{/* ✅ Mobile: Icons + Counts direkt rechts neben Recorder */}
<div className="flex items-center gap-1.5 shrink-0">
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="online"
>
<SignalIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineModelsCount}</span>
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="Watched online"
>
<EyeIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineWatchedModelsCount}</span>
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="Fav online"
>
<HeartIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineFavCount}</span>
</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
title="Like online"
>
<HandThumbUpIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineLikedCount}</span>
</span>
</div>
<div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
</div>
{/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
<div className="sm:hidden mt-1 w-full">
<div className="text-[11px] text-gray-500 dark:text-gray-400">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
<div className="mt-2 flex items-stretch gap-2">
{showPerfMon ? <PerformanceMonitor mode="inline" className="flex-1" /> : <div className="flex-1" />}
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
className="px-3 shrink-0"
>
Cookies
</Button>
<Button
variant="secondary"
onClick={logout}
className="px-3 shrink-0"
>
Abmelden
</Button>
</div>
</div>
</div>
</div>
<div className="hidden sm:flex items-center gap-2 h-full">
{showPerfMon ? <PerformanceMonitor mode="inline" /> : null}
<Button variant="secondary" onClick={() => setCookieModalOpen(true)} className="h-9 px-3">
Cookies
</Button>
<Button variant="secondary" onClick={logout} className="h-9 px-3">
Abmelden
</Button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
<div className="relative">
<label className="sr-only">Source URL</label>
<TextInput
ref={sourceUrlInputRef}
value={sourceUrl}
size='sm'
onChange={(e) => setSourceUrl(e.target.value)}
selectAllOnFocus
selectAllOnMouseDown
placeholder="https://…"
/>
</div>
<Button variant="primary" onClick={onStart} disabled={!canStart} className="w-full sm:w-auto rounded-lg">
Start
</Button>
</div>
{error ? (
<div className="rounded-lg 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 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="text-xs text-amber-700 dark:text-amber-300">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
</div>
) : null}
{busy ? (
<div className="pt-1">
<ProgressBar label="Starte Download…" indeterminate />
</div>
) : null}
<div className="hidden sm:block pt-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
</header>
<div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
<main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 space-y-2">
{selectedTab === 'running' ? (
<Downloads
jobs={jobs}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
pending={pendingWatchedRooms}
autostartState={autostartState}
onRefreshAutostartState={async () => {
try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
setAutostartState({
paused: Boolean(s?.paused),
pausedByUser: Boolean(s?.pausedByUser),
pausedByDisk: Boolean(s?.pausedByDisk),
})
} 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={setDonePageSize}
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}
assetNonce={assetNonce}
sortMode={doneSort}
onSortModeChange={(m) => {
setDoneSort(m)
setDonePage(1)
}}
loadMode="paged"
includeKeep={includeKeep}
onIncludeKeepChange={(checked) => {
setIncludeKeep(checked)
setDonePage(1)
donePrefetchRef.current = null
}}
/>
) : null}
{selectedTab === 'models' ? (
<ModelsTab onAddToDownloads={handleAddToDownloads} />
) : 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={async ({ job, splits, segments }) => {
console.log('VIDEO_SPLIT_APPLY', {
jobId: job.id,
output: job.output,
splits,
segments,
})
}}
/>
{playerJob ? (
<Player
key={[
String((playerJob as any)?.id ?? ''),
baseName(playerJob.output || ''),
// optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
String(assetNonce),
].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>
)
}