nsfwapp/frontend/src/App.tsx
2026-02-20 18:18:59 +01:00

2908 lines
97 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 } 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'
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 RecorderSettings = {
recordDir: string
doneDir: string
ffmpegPath?: string
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
autoDeleteSmallDownloadsBelowMB?: number
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
lowDiskPauseBelowGB: 3000,
}
type StoredModel = {
id: string
input: string
host?: string
modelKey: string
watching: boolean
favorite?: boolean
liked?: boolean | null
isUrl?: boolean
path?: 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 ChaturbateOnlineRoom = {
username?: string
current_show?: string
chat_room_url?: string
image_url?: string
}
type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
total?: number
}
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 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 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
}
export default function App() {
const [authChecked, setAuthChecked] = useState(false)
const [authed, setAuthed] = useState(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)
setModelsByKey({})
setModelsCount(0)
setCbOnlineByKeyLower({})
cbOnlineByKeyLowerRef.current = {}
startedToastByJobIdRef.current = {}
jobsInitDoneRef.current = false
setPendingWatchedRooms([])
setPendingAutoStartByKey({})
setOnlineModelsCount(0)
// optional: URL-Feld leeren
setSourceUrl('')
}
}, [])
useEffect(() => {
void checkAuth()
}, [checkAuth])
const notify = useNotify()
const notifyRef = useRef(notify)
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
const cookieProblemLastAtRef = useRef(0)
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 DONE_PAGE_SIZE = 8
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 donePrefetchRef = useRef<DonePrefetch | null>(null)
const donePrefetchInFlightRef = useRef(false)
// ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
const doneFetchAbortRef = useRef<AbortController | null>(null)
const doneFetchInFlightRef = useRef(false)
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
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=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ 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])
const loadDoneCount = useCallback(async () => {
try {
// ✅ leichtgewichtiger Endpoint (siehe Backend unten)
const res = await fetch(`/api/record/done/meta`, { 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)
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const requestFinishedReload = useCallback(() => {
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
}, [])
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)
// akzeptiere: Array oder { items: [] }
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setJobs(items)
jobsRef.current = items
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [])
useEffect(() => {
try {
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
} catch {}
}, [doneSort])
useEffect(() => {
if (!authed) return
void loadDoneCount()
}, [authed, loadDoneCount])
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 [onlineModelsCount, setOnlineModelsCount] = useState(0)
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
useEffect(() => {
const t = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(t)
}, [])
const lower = (s: string) => (s || '').toLowerCase().trim()
const formatAgoDE = (diffMs: number) => {
const s = Math.max(0, Math.floor(diffMs / 1000))
if (s < 2) return 'gerade eben'
if (s < 60) return `vor ${s} Sekunden`
const m = Math.floor(s / 60)
if (m === 1) return 'vor 1 Minute'
if (m < 60) return `vor ${m} Minuten`
const h = Math.floor(m / 60)
if (h === 1) return 'vor 1 Stunde'
return `vor ${h} Stunden`
}
const headerUpdatedText = useMemo(() => {
const diff = nowMs - lastHeaderUpdateAtMs
return `(zuletzt aktualisiert: ${formatAgoDE(diff)})`
}, [nowMs, lastHeaderUpdateAtMs])
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
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 refreshModelsByKey = useCallback(async () => {
try {
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : []))
setLastHeaderUpdateAtMs(Date.now())
} catch {
// ignore
}
}, [buildModelsByKey])
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
const [detailsModelKey, setDetailsModelKey] = useState<string | null>(null)
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)
}, [])
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)
}, [])
useEffect(() => {
// initial laden
void refreshModelsByKey()
const onChanged = (ev: Event) => {
const e = ev as CustomEvent<any>
const detail = e?.detail ?? {}
const updated = detail?.model
// ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models/list)
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))
setLastHeaderUpdateAtMs(Date.now())
return
}
// ✅ 2) Delete-Event (optional): ebenfalls ohne Voll-Refresh
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))
}
setLastHeaderUpdateAtMs(Date.now())
return
}
// ✅ 3) Nur wenn kein Model/keine Delete-Info mitkommt: kompletter Refresh
void refreshModelsByKey()
}
window.addEventListener('models-changed', onChanged as any)
return () => window.removeEventListener('models-changed', onChanged as any)
}, [refreshModelsByKey, upsertModelCache])
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 [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
const [playerExpanded, setPlayerExpanded] = useState(false)
const [assetNonce, setAssetNonce] = useState(0)
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
const recSettingsRef = useRef(recSettings)
useEffect(() => {
recSettingsRef.current = recSettings
}, [recSettings])
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)
useEffect(() => {
busyRef.current = busy
}, [busy])
useEffect(() => {
cookiesRef.current = cookies
}, [cookies])
useEffect(() => {
jobsRef.current = jobs
}, [jobs])
// pending start falls gerade busy
const pendingStartUrlRef = useRef<string | null>(null)
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
}, [])
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
[]
)
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) => [created, ...prev])
jobsRef.current = [created, ...jobsRef.current]
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) {
const kLower = next.pendingKeyLower
setPendingAutoStartByKey((prev) => {
const copy = { ...(prev || {}) }
delete copy[kLower]
pendingAutoStartByKeyRef.current = copy
return copy
})
}
} 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()
}
}
})()
}
}
// ✅ Zentraler Snapshot: username(lower) -> room
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
const lastCbShowByKeyLowerRef = useRef<Record<string, string>>({})
// ✅ merkt sich, ob ein Model im letzten Snapshot überhaupt online war
const lastCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})
// ✅ verhindert Toast-Spam direkt beim ersten Poll (Startup)
const cbOnlineInitDoneRef = useRef(false)
// ✅ merkt sich, ob ein Model seit App-Start schon einmal online war
const everCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})
useEffect(() => {
cbOnlineByKeyLowerRef.current = cbOnlineByKeyLower
}, [cbOnlineByKeyLower])
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 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])
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
const modelsByKeyRef = useRef(modelsByKey)
useEffect(() => {
modelsByKeyRef.current = modelsByKey
}, [modelsByKey])
const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
useEffect(() => {
pendingAutoStartByKeyRef.current = pendingAutoStartByKey
}, [pendingAutoStartByKey])
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
useEffect(() => {
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
}, [chaturbateStoreKeysLower])
const selectedTabRef = useRef(selectedTab)
useEffect(() => {
selectedTabRef.current = selectedTab
}, [selectedTab])
// ✅ 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
// ✅ Chaturbate: parse modelKey + queue-logic über Snapshot
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) {
// 1) Wenn busy: IMMER queue (auch public, damit "public queued" sichtbar bleibt)
if (busyRef.current) {
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
return true
}
// 2) Wenn Snapshot sagt: online aber NICHT public -> queue
const room = cbOnlineByKeyLowerRef.current[mkLower]
const show = String(room?.current_show ?? '')
if (room && show && show !== 'public') {
setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
return true
}
}
} catch {
// parse fail -> normal starten
}
} else {
// Nicht-Chaturbate-API: wenn busy, wenigstens "pendingStart" setzen
if (busyRef.current) {
pendingStartUrlRef.current = norm
return true
}
}
if (busyRef.current) return false
setBusy(true)
busyRef.current = 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) => [created, ...prev])
jobsRef.current = [created, ...jobsRef.current]
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
} finally {
setBusy(false)
busyRef.current = false
}
}, [])
// ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const s = await apiJSON<RecorderSettings>('/api/settings', { cache: 'no-store' })
if (!cancelled && s) setRecSettings({ ...DEFAULT_RECORDER_SETTINGS, ...s })
} catch {
// ignore
}
}
const onUpdated = () => void load()
const onFocus = () => void load()
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus)
load()
return () => {
cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus)
}
}, [])
// ✅ Models-Count (leicht)
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' })
const c = Number(meta?.count ?? 0)
if (!cancelled && Number.isFinite(c)) {
setModelsCount(c)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
// ignore
}
}
load()
const t = window.setInterval(load, document.hidden ? 60000 : 30000)
return () => {
cancelled = true
window.clearInterval(t)
}
}, [])
const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies])
const openPlayer = useCallback((job: RecordJob) => {
modelsCacheRef.current = null
setPlayerModel(null)
setPlayerJob(job)
setPlayerExpanded(false)
}, [])
const runningJobs = jobs.filter((j) => {
const s = String((j as any)?.status ?? '').toLowerCase()
return s === 'running' || s === 'postwork'
})
// ✅ Anzahl Watched Models (aus Store), die online sind
const onlineWatchedModelsCount = useMemo(() => {
let c = 0
for (const m of Object.values(modelsByKey)) {
if (!m?.watching) continue
if (!isChaturbateStoreModel(m)) continue
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
if (cbOnlineByKeyLower[k]) c++
}
return c
}, [modelsByKey, cbOnlineByKeyLower, isChaturbateStoreModel])
const { onlineFavCount, onlineLikedCount } = useMemo(() => {
let fav = 0
let liked = 0
for (const m of Object.values(modelsByKey)) {
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
if (!cbOnlineByKeyLower[k]) continue
if (m?.favorite) fav++
if (m?.liked === true) liked++
}
return { onlineFavCount: fav, onlineLikedCount: liked }
}, [modelsByKey, cbOnlineByKeyLower])
const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount },
{ id: 'categories', label: 'Kategorien' },
{ id: 'settings', label: 'Einstellungen' },
]
const canStart = useMemo(() => sourceUrl.trim().length > 0 && !busy, [sourceUrl, busy])
// 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(() => {
// 1x initial / bei sort-wechsel (für Badge)
void loadDoneCount()
const onVis = () => {
if (!document.hidden) void loadDoneCount()
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [loadDoneCount])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
// ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
if (doneFetchInFlightRef.current) {
doneFetchAbortRef.current?.abort()
}
const ac = new AbortController()
doneFetchAbortRef.current = ac
doneFetchInFlightRef.current = true
try {
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const res = await fetch(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
// Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
if (target !== wanted) {
const res2 = await fetch(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
const data2 = await res2.json().catch(() => null)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
} else {
setDoneJobs(items)
}
setLastHeaderUpdateAtMs(Date.now())
} catch (e: any) {
// Abort ist ok
if (String(e?.name) !== 'AbortError') {
// optional: console.debug('[DONE] refresh failed', e)
}
} finally {
// ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
// Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
const isCurrent = doneFetchAbortRef.current === ac
if (isCurrent) {
doneFetchAbortRef.current = null
doneFetchInFlightRef.current = false
}
}
},
[donePage, doneSort]
)
useEffect(() => {
if (selectedTab !== 'finished') return
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount()
requestFinishedReload()
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
requestFinishedReload()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, loadDoneCount, requestFinishedReload])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
useEffect(() => {
if (!authed) return
let es: EventSource | null = null
let timer: number | null = null
const stopPoll = () => {
if (timer != null) {
window.clearInterval(timer)
timer = null
}
}
const startPoll = () => {
if (timer != null) return
timer = window.setInterval(() => {
if (document.hidden) return
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, document.hidden ? 60000 : 15000)
}
const lastFireRef = { t: 0 }
let coalesceTimer: number | null = null
const requestRefresh = () => {
const now = Date.now()
const since = now - lastFireRef.t
// coalesce bursts
if (since < 800) {
if (coalesceTimer != null) return
coalesceTimer = window.setTimeout(() => {
coalesceTimer = null
lastFireRef.t = Date.now()
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}, 900)
return
}
lastFireRef.t = now
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
} else {
void loadDoneCount()
}
}
// initial
void loadDoneCount()
es = new EventSource('/api/record/done/stream')
es.onopen = () => {
// ✅ sobald SSE stabil da ist: Poll aus
stopPoll()
}
es.onerror = () => {
// ✅ SSE kaputt -> Poll an
startPoll()
}
const onDone = () => requestRefresh()
es.addEventListener('doneChanged', onDone as any)
const onVis = () => {
if (!document.hidden) requestRefresh()
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
if (coalesceTimer != null) window.clearTimeout(coalesceTimer)
stopPoll()
es?.removeEventListener('doneChanged', onDone as any)
es?.close()
es = null
}
}, [authed, loadDoneCount, requestFinishedReload])
useEffect(() => {
if (!authed) return
// initial
void loadJobs()
// polling: schneller wenn running-tab offen oder jobs laufen
const t = window.setInterval(() => {
if (document.hidden) return
const hasRunning = jobsRef.current.some((j) => {
const s = String((j as any)?.status ?? '').toLowerCase()
return s === 'running' || s === 'postwork'
})
// wenn Tab "running" offen ODER irgendwas läuft -> häufiger pollen
if (selectedTabRef.current === 'running' || hasRunning) {
void loadJobs()
}
}, document.hidden ? 60000 : 3000) // 3s fühlt sich "live" an
const onVis = () => {
if (!document.hidden) void loadJobs()
}
document.addEventListener('visibilitychange', onVis)
return () => {
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [authed, loadJobs])
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))
}
}
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) {
void loadDoneCount()
requestFinishedReload()
return
}
// ✅ Tabs sofort updaten (optimistisch)
setDoneCount((c) => Math.max(0, c + delta))
// ✅ danach server-truth holen + ALL reload
void loadDoneCount()
requestFinishedReload()
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [loadDoneCount, requestFinishedReload])
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() {
return startUrl(sourceUrl)
}
const handleAddToDownloads = useCallback(
async (job: RecordJob): Promise<boolean> => {
const raw = String((job as any)?.sourceUrl ?? '')
const url0 = extractFirstUrl(raw)
if (!url0) return false
const norm0 = normalizeHttpUrl(url0)
if (!norm0) 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 handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
)
try {
// ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
const data = await apiJSON<{ undoToken?: string }>(
`/api/record/delete?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
)
window.setTimeout(() => {
setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
// ✅ sofort auffüllen, wenn wir Platz haben
const need = DONE_PAGE_SIZE - 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 < DONE_PAGE_SIZE && 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)
}
// buffer zurückschreiben (mit verkürzter items-Liste)
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Count sofort optimistisch runter
setDoneCount((c) => Math.max(0, c - 1))
// ✅ Player / jobs cleanup wie bei dir
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Buffer direkt wieder nachfüllen (background)
void prefetchDonePage(donePage + 1)
}, 320)
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {} // ✅ kein null mehr
} catch (e: any) {
window.dispatchEvent(
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
)
notify.error('Löschen fehlgeschlagen: ', file)
return // ✅ void statt null
}
},
[notify, refreshDoneNow]
)
const handleDeleteJob = useCallback(
async (job: RecordJob): Promise<void> => {
await handleDeleteJobWithUndo(job)
},
[handleDeleteJobWithUndo]
)
const handleKeepJob = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }))
try {
await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))
window.setTimeout(() => {
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
}, 320)
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', file)
return
}
},
[selectedTab, refreshDoneNow, notify]
)
const handleToggleHot = useCallback(
async (job: RecordJob) => {
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 } }))
// kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
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' }
)
// ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player)
window.dispatchEvent(
new CustomEvent('finished-downloads:rename', {
detail: { oldFile: res.oldFile, newFile: res.newFile },
})
)
const apply = (out: string) => replaceBasename(out || '', res.newFile)
// ✅ 1) Player immer updaten
setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))
// ✅ 2) doneJobs über ID (Fallback: basename)
setDoneJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
return match ? { ...j, output: apply(j.output || '') } : j
})
)
// ✅ 3) jobs (/record/list) über ID (Fallback: basename)
setJobs((prev) =>
prev.map((j) => {
const match = j.id === job.id || baseName(j.output || '') === file
return match ? { ...j, output: apply(j.output || '') } : j
})
)
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
return
}
},
[notify]
)
// --- 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)
// helper: host schnell aus job holen (für flags ohne id)
const hostFromJob = (j: RecordJob): string => {
try {
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
const u = extractFirstUrl(urlFromJob)
if (!u) return ''
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
} catch {
return ''
}
}
// ✅ Fast-Path: modelKey aus Dateiname => instant optimistic + 1x flags call
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
if (keyFromFile) {
const guardKey = keyFromFile
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev =
modelsByKey[keyFromFile] ??
({
id: '', // unknown (ok)
input: '',
host: hostFromJob(job) || undefined,
modelKey: keyFromFile,
watching: false,
favorite: false,
liked: null,
isUrl: false,
} as StoredModel)
const nextFav = !Boolean(prev.favorite)
const optimistic: StoredModel = {
...prev,
modelKey: prev.modelKey || keyFromFile,
favorite: nextFav,
liked: nextFav ? false : prev.liked,
}
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
...(optimistic.id ? { id: optimistic.id } : {}),
host: optimistic.host || hostFromJob(job) || '',
modelKey: keyFromFile,
favorite: nextFav,
...(nextFav ? { liked: false } : {}),
})
// ✅ Model wurde gelöscht (204)
if (!updated) {
setModelsByKey((p) => {
const { [keyFromFile]: _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: keyFromFile } })
)
return
}
const k = lower(updated.modelKey || keyFromFile)
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) {
// rollback
setModelsByKey((p) => ({ ...p, [keyFromFile]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
return
}
// ✅ Fallback: keinen modelKey aus Filename -> wie bisher resolve+ensure
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return
const guardKey = lower(m.modelKey || m.id || '')
if (!guardKey) return
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev = m
const nextFav = !Boolean(prev.favorite)
const optimistic: StoredModel = {
...prev,
favorite: nextFav,
liked: nextFav ? false : prev.liked,
}
const kPrev = lower(prev.modelKey || '')
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
id: prev.id,
favorite: nextFav,
...(nextFav ? { liked: false } : {}),
})
if (!updated) {
setModelsByKey((p) => {
const k = lower(prev.modelKey || '')
if (!k) return p
const { [k]: _drop, ...rest } = p
return rest
})
removeModelCache(prev.id)
if (sameAsPlayer) setPlayerModel(null)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
)
return
}
const k = lower(updated.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) {
const k = lower(prev.modelKey || '')
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, playerModel, resolveModelForJob, 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 hostFromJob = (j: RecordJob): string => {
try {
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
const u = extractFirstUrl(urlFromJob)
if (!u) return ''
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
} catch {
return ''
}
}
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
if (keyFromFile) {
const guardKey = keyFromFile
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev =
modelsByKey[keyFromFile] ??
({
id: '',
input: '',
host: hostFromJob(job) || undefined,
modelKey: keyFromFile,
watching: false,
favorite: false,
liked: null,
isUrl: false,
} as StoredModel)
const nextLiked = !(prev.liked === true)
const optimistic: StoredModel = {
...prev,
modelKey: prev.modelKey || keyFromFile,
liked: nextLiked,
favorite: nextLiked ? false : prev.favorite,
}
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
...(optimistic.id ? { id: optimistic.id } : {}),
host: optimistic.host || hostFromJob(job) || '',
modelKey: keyFromFile,
liked: nextLiked,
...(nextLiked ? { favorite: false } : {}),
})
if (!updated) {
setModelsByKey((p) => {
const { [keyFromFile]: _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: keyFromFile } })
)
return
}
const k = lower(updated.modelKey || keyFromFile)
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, [keyFromFile]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
return
}
// fallback
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return
const guardKey = lower(m.modelKey || m.id || '')
if (!guardKey) return
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev = m
const nextLiked = !(prev.liked === true)
const optimistic: StoredModel = {
...prev,
liked: nextLiked,
favorite: nextLiked ? false : prev.favorite,
}
const kPrev = lower(prev.modelKey || '')
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = nextLiked
? await patchModelFlags({ id: prev.id, liked: true, favorite: false })
: await patchModelFlags({ id: prev.id, liked: false })
if (!updated) {
setModelsByKey((p) => {
const k = lower(prev.modelKey || '')
if (!k) return p
const { [k]: _drop, ...rest } = p
return rest
})
removeModelCache(prev.id)
if (sameAsPlayer) setPlayerModel(null)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
)
return
}
const k = lower(updated.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) {
const k = lower(prev.modelKey || '')
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, playerModel, resolveModelForJob, 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 hostFromJob = (j: RecordJob): string => {
try {
const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
const u = extractFirstUrl(urlFromJob)
if (!u) return ''
return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
} catch {
return ''
}
}
const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
if (keyFromFile) {
const guardKey = keyFromFile
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev =
modelsByKey[keyFromFile] ??
({
id: '',
input: '',
host: hostFromJob(job) || undefined,
modelKey: keyFromFile,
watching: false,
favorite: false,
liked: null,
isUrl: false,
} as StoredModel)
const nextWatching = !Boolean(prev.watching)
const optimistic: StoredModel = {
...prev,
modelKey: prev.modelKey || keyFromFile,
watching: nextWatching,
}
setModelsByKey((p) => ({ ...p, [keyFromFile]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({
...(optimistic.id ? { id: optimistic.id } : {}),
host: optimistic.host || hostFromJob(job) || '',
modelKey: keyFromFile,
watched: nextWatching, // ✅ API key watched => watching in DB
})
if (!updated) {
setModelsByKey((p) => {
const { [keyFromFile]: _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: keyFromFile } })
)
return
}
const k = lower(updated.modelKey || keyFromFile)
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, [keyFromFile]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
return
}
// fallback
let m = sameAsPlayer ? playerModel : null
if (!m) m = await resolveModelForJob(job, { ensure: true })
if (!m) return
const guardKey = lower(m.modelKey || m.id || '')
if (!guardKey) return
if (flagsInFlightRef.current[guardKey]) return
flagsInFlightRef.current[guardKey] = true
const prev = m
const nextWatching = !Boolean(prev.watching)
const optimistic: StoredModel = {
...prev,
watching: nextWatching,
}
const kPrev = lower(prev.modelKey || '')
if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
upsertModelCache(optimistic)
if (sameAsPlayer) setPlayerModel(optimistic)
try {
const updated = await patchModelFlags({ id: prev.id, watched: nextWatching })
if (!updated) {
setModelsByKey((p) => {
const k = lower(prev.modelKey || '')
if (!k) return p
const { [k]: _drop, ...rest } = p
return rest
})
removeModelCache(prev.id)
if (sameAsPlayer) setPlayerModel(null)
window.dispatchEvent(
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey: prev.modelKey } })
)
return
}
const k = lower(updated.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) {
const k = lower(prev.modelKey || '')
if (k) setModelsByKey((p) => ({ ...p, [k]: prev }))
upsertModelCache(prev)
if (sameAsPlayer) setPlayerModel(prev)
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
} finally {
delete flagsInFlightRef.current[guardKey]
}
},
[notify, playerJob, playerModel, resolveModelForJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
)
async function resolveModelForJob(
job: RecordJob,
opts?: { ensure?: boolean }
): Promise<StoredModel | null> {
const wantEnsure = Boolean(opts?.ensure)
const upsertCache = (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 resolveByKey = async (key: string): Promise<StoredModel | null> => {
if (!key) return null
const needle = key.trim().toLowerCase()
if (!needle) return null
// ✅ 0) Sofort aus App-State (schnellster Pfad)
const stateHit = modelsByKey[needle]
if (stateHit) {
upsertCache(stateHit)
return stateHit
}
// ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models/list)
if (wantEnsure) {
let host: string | undefined
// versuche Host aus sourceUrl zu nehmen (falls vorhanden)
try {
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
const url = extractFirstUrl(urlFromJob)
if (url) host = new URL(url).hostname.replace(/^www\./i, '').toLowerCase()
} catch {}
const ensured = await apiJSON<StoredModel>('/api/models/ensure', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ modelKey: key, ...(host ? { host } : {}) }),
})
upsertModelCache(ensured)
return ensured
}
// ✅ 2) Cache refreshen / initial füllen (nur wenn KEIN ensure)
const now = Date.now()
const cached = modelsCacheRef.current
if (!cached || now - cached.ts > 30_000) {
// erst aus State seeden (falls vorhanden)
const seeded = Object.values(modelsByKey)
if (seeded.length) {
modelsCacheRef.current = { ts: now, list: seeded }
} else {
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] }
}
}
const list = modelsCacheRef.current?.list ?? []
// ✅ 3) im Cache suchen
const hit = list.find((m) => (m.modelKey || '').trim().toLowerCase() === needle)
if (hit) return hit
return null
}
const keyFromFile = modelKeyFromFilename(job.output || '')
// ✅ Wichtig: IMMER zuerst Dateiname (auch bei running)
// -> spart /parse + /upsert und macht Toggle instant
if (keyFromFile) {
return resolveByKey(keyFromFile)
}
const isRunning = job.status === 'running'
// ✅ Nur wenn wir wirklich KEINEN Key aus dem Output haben:
const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
const url = extractFirstUrl(urlFromJob)
if (isRunning && url) {
const parsed = await apiJSON<any>('/api/models/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: url }),
})
const saved = await apiJSON<StoredModel>('/api/models/upsert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsed),
})
upsertModelCache(saved)
return saved
}
// Fallback: wenn gar nix geht
return null
}
// 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('hover', kick)
document.addEventListener('visibilitychange', kick)
schedule(0)
return () => {
cancelled = true
if (timer) window.clearTimeout(timer)
window.removeEventListener('hover', kick)
document.removeEventListener('visibilitychange', kick)
}
}, [autoAddEnabled, autoStartEnabled, startUrl])
useEffect(() => {
const stop = startChaturbateOnlinePolling({
getModels: () => {
if (!recSettingsRef.current.useChaturbateApi) return []
const modelsMap = modelsByKeyRef.current
const pendingMap = pendingAutoStartByKeyRef.current
const watchedKeysLower = Object.values(modelsMap)
.filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
.map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
.filter(Boolean)
const queuedKeysLower = Object.keys(pendingMap || {})
.map((k) => String(k || '').trim().toLowerCase())
.filter(Boolean)
// ✅ NUR watched + queued pollen (Store kann riesig sein -> lag)
// Wenn du Store-Online später willst: extra, seltener Poll (z.B. 60s) separat lösen.
return Array.from(new Set([...watchedKeysLower, ...queuedKeysLower]))
},
getShow: () => ['public', 'private', 'hidden', 'away'],
intervalMs: 8000,
onData: (data: ChaturbateOnlineResponse) => {
void (async () => {
if (!data?.enabled) {
setCbOnlineByKeyLower({})
cbOnlineByKeyLowerRef.current = {}
lastCbShowByKeyLowerRef.current = {}
setPendingWatchedRooms([])
everCbOnlineByKeyLowerRef.current = {}
cbOnlineInitDoneRef.current = false
lastCbOnlineByKeyLowerRef.current = {}
setLastHeaderUpdateAtMs(Date.now())
return
}
const nextSnap: Record<string, ChaturbateOnlineRoom> = {}
for (const r of Array.isArray(data.rooms) ? data.rooms : []) {
const u = String(r?.username ?? '').trim().toLowerCase()
if (u) nextSnap[u] = r
}
setCbOnlineByKeyLower(nextSnap)
cbOnlineByKeyLowerRef.current = nextSnap
// ✅ Toasts: (A) watched offline->online, (B) waiting->public, (C) online->offline->online => "wieder online"
try {
const notificationsOn = Boolean((recSettingsRef.current as any).enableNotifications ?? true)
const waiting = new Set(['private', 'away', 'hidden'])
// watched-Keys (nur Chaturbate)
const watchedSetLower = new Set(
Object.values(modelsByKeyRef.current || {})
.filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
.map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
.filter(Boolean)
)
const prevShow = lastCbShowByKeyLowerRef.current || {}
const nextShowMap: Record<string, string> = { ...prevShow }
const prevOnline = lastCbOnlineByKeyLowerRef.current || {}
const isInitial = !cbOnlineInitDoneRef.current
// ✅ "war schon mal online" Snapshot (vor diesem Poll)
const everOnline = everCbOnlineByKeyLowerRef.current || {}
const nextEverOnline: Record<string, true> = { ...everOnline }
for (const [keyLower, room] of Object.entries(nextSnap)) {
const nowShow = String((room as any)?.current_show ?? '').toLowerCase().trim()
const beforeShow = String(prevShow[keyLower] ?? '').toLowerCase().trim()
const wasOnline = Boolean(prevOnline[keyLower])
const isOnline = true // weil es in nextSnap ist
const becameOnline = isOnline && !wasOnline
// ✅ war irgendwann schon mal online (vor diesem Poll)?
const hadEverBeenOnline = Boolean(everOnline[keyLower])
const name = String((room as any)?.username ?? keyLower).trim() || keyLower
const imageUrl = String((room as any)?.image_url ?? '').trim()
// immer merken: jetzt ist es online
nextEverOnline[keyLower] = true
// (B) waiting -> public => "wieder online" (höchste Priorität, damit kein Doppel-Toast)
const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
if (becamePublicFromWaiting) {
if (notificationsOn) {
notify.info(name, 'ist wieder online.', {
imageUrl,
imageAlt: `${name} Vorschau`,
durationMs: 5500,
})
}
if (nowShow) nextShowMap[keyLower] = nowShow
continue
}
// (A/C) watched: offline -> online
if (watchedSetLower.has(keyLower) && becameOnline) {
// C: online->offline->online => "wieder online"
const cameBackFromOffline = hadEverBeenOnline
// Startup-Spam vermeiden
if (notificationsOn && !isInitial) {
notify.info(
name,
cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
{
imageUrl,
imageAlt: `${name} Vorschau`,
durationMs: 5500,
}
)
}
}
if (nowShow) nextShowMap[keyLower] = nowShow
}
// Presence-Snapshot merken
const nextOnline: Record<string, true> = {}
for (const k of Object.keys(nextSnap)) nextOnline[k] = true
lastCbOnlineByKeyLowerRef.current = nextOnline
// ✅ "ever online" merken
everCbOnlineByKeyLowerRef.current = nextEverOnline
cbOnlineInitDoneRef.current = true
lastCbShowByKeyLowerRef.current = nextShowMap
} catch {
// ignore
}
// Online-Keys für Store
const storeKeys = chaturbateStoreKeysLowerRef.current
const nextOnlineStore: Record<string, true> = {}
for (const k of storeKeys || []) {
const kl = String(k || '').trim().toLowerCase()
if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
}
// Pending Watched Rooms (nur im running Tab)
if (!recSettingsRef.current.useChaturbateApi) {
setPendingWatchedRooms([])
} else if (selectedTabRef.current !== 'running') {
// optional: nicht leeren
} else {
const modelsMap = modelsByKeyRef.current
const pendingMap = pendingAutoStartByKeyRef.current
const watchedKeysLower = Array.from(
new Set(
Object.values(modelsMap)
.filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
.map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
.filter(Boolean)
)
)
const queuedKeysLower = Object.keys(pendingMap || {})
.map((k) => String(k || '').trim().toLowerCase())
.filter(Boolean)
const queuedSetLower = new Set(queuedKeysLower)
const keysToCheckLower = Array.from(new Set([...watchedKeysLower, ...queuedKeysLower]))
if (keysToCheckLower.length === 0) {
setPendingWatchedRooms([])
} else {
const nextPending: PendingWatchedRoom[] = []
for (const keyLower of keysToCheckLower) {
const room = nextSnap[keyLower]
if (!room) continue
const username = String(room?.username ?? '').trim()
const currentShow = String(room?.current_show ?? 'unknown')
if (currentShow === 'public' && !queuedSetLower.has(keyLower)) continue
const canonicalUrl = `https://chaturbate.com/${(username || keyLower).trim()}/`
nextPending.push({
id: keyLower,
modelKey: username || keyLower,
url: canonicalUrl,
currentShow,
imageUrl: String((room as any)?.image_url ?? ''),
})
}
nextPending.sort((a, b) => a.modelKey.localeCompare(b.modelKey, undefined, { sensitivity: 'base' }))
setPendingWatchedRooms(nextPending)
}
}
// queued auto-start
if (!recSettingsRef.current.useChaturbateApi) return
if (busyRef.current) return
const pendingMap = pendingAutoStartByKeyRef.current
const keys = Object.keys(pendingMap || {})
.map((k) => String(k || '').toLowerCase())
.filter(Boolean)
for (const kLower of keys) {
const room = nextSnap[kLower]
if (!room) continue
if (String(room.current_show ?? '') !== 'public') continue
const url = pendingMap[kLower]
if (!url) continue
// ✅ nicht mehr seriell awaiten, sondern in die Start-Queue
enqueueStart({ url, silent: true, pendingKeyLower: kLower })
}
setLastHeaderUpdateAtMs(Date.now())
})()
},
})
return () => stop()
}, [])
useEffect(() => {
// ✅ nur sinnvoll, wenn Chaturbate API aktiv ist
if (!recSettings.useChaturbateApi) {
setOnlineModelsCount(0)
return
}
const stop = startChaturbateOnlinePolling({
// ✅ leer => ALL-mode (durch fetchAllWhenNoModels)
getModels: () => [],
getShow: () => ['public', 'private', 'hidden', 'away'],
// deutlich seltener, weil potentiell groß
intervalMs: 30000,
fetchAllWhenNoModels: true,
onData: (data) => {
if (!data?.enabled) {
setOnlineModelsCount(0)
return
}
const total = Number((data as any)?.total ?? 0)
setOnlineModelsCount(Number.isFinite(total) ? total : 0)
setLastHeaderUpdateAtMs(Date.now())
},
onError: (e) => {
console.error('[ALL-online poller] error', e)
},
})
return () => stop()
}, [recSettings.useChaturbateApi])
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-30 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 py-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">
{headerUpdatedText}
</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">
{headerUpdatedText}
</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>
<input
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
placeholder="https://…"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
</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-20 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 py-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-6 space-y-6">
{selectedTab === 'running' ? (
<Downloads
jobs={runningJobs}
modelsByKey={modelsByKey}
pending={pendingWatchedRooms}
onOpenPlayer={openPlayer}
onStopJob={stopJob}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onAddToDownloads={handleAddToDownloads}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
) : null}
{selectedTab === 'finished' ? (
<FinishedDownloads
jobs={jobs}
modelsByKey={modelsByKey}
doneJobs={doneJobs}
doneTotal={doneCount}
page={donePage}
pageSize={DONE_PAGE_SIZE}
onPageChange={setDonePage}
onOpenPlayer={openPlayer}
onDeleteJob={handleDeleteJobWithUndo}
onToggleHot={handleToggleHot}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
blurPreviews={Boolean(recSettings.blurPreviews)}
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
teaserAudio={Boolean(recSettings.teaserAudio)}
assetNonce={assetNonce}
sortMode={doneSort}
onSortModeChange={(m) => {
setDoneSort(m)
setDonePage(1)
}}
loadMode="all"
/>
) : null}
{selectedTab === 'models' ? <ModelsTab /> : 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={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
onToggleHot={handleToggleHot}
onDelete={handleDeleteJob}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
/>
{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)}
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>
)
}