nsfwapp/frontend/src/App.tsx
2026-01-13 14:00:05 +01:00

2063 lines
69 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.

// 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 } from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
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
}
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
autoAddToDownloadList: false,
autoStartAddedDownloads: false,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 50,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
}
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[]
}
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
}
}
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 notify = useNotify()
const DONE_PAGE_SIZE = 8
type DoneSortMode =
| 'completed_desc'
| 'completed_asc'
| 'model_asc'
| 'model_desc'
| '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'
}
})
useEffect(() => {
try {
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
} catch {}
}, [doneSort])
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 [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 raw = (e.detail?.modelKey ?? '').trim()
let k = raw.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)
}
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 assetsBumpTimerRef = useRef<number | null>(null)
const bumpAssetsTwice = useCallback(() => {
bumpAssets()
if (assetsBumpTimerRef.current) window.clearTimeout(assetsBumpTimerRef.current)
assetsBumpTimerRef.current = window.setTimeout(() => bumpAssets(), 3500)
}, [bumpAssets])
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[]>([])
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>('')
// ✅ Online-Status für Models aus dem Model-Store
const [onlineStoreKeysLower, setOnlineStoreKeysLower] = useState<Record<string, true>>({})
// ✅ Zentraler Snapshot: username(lower) -> room
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
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 norm = normalizeHttpUrl(rawUrl)
if (!norm) return false
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 (j.status !== 'running') return false
const jNorm = normalizeHttpUrl(String((j as any).sourceUrl || ''))
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 }),
})
setJobs((prev) => [created, ...prev])
jobsRef.current = [created, ...jobsRef.current]
return true
} catch (e: any) {
if (!silent) setError(e?.message ?? String(e))
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('hover', onFocus)
document.addEventListener('visibilitychange', onFocus)
load()
return () => {
cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('hover', 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) => j.status === 'running')
const onlineModelsCount = useMemo(() => {
let c = 0
for (const m of Object.values(modelsByKey)) {
const k = lower(String(m?.modelKey ?? ''))
if (!k) continue
if (onlineStoreKeysLower[k]) c++
}
return c
}, [modelsByKey, onlineStoreKeysLower])
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 (!onlineStoreKeysLower[k]) continue
if (m?.favorite) fav++
if (m?.liked === true) liked++
}
return { onlineFavCount: fav, onlineLikedCount: liked }
}, [modelsByKey, onlineStoreKeysLower])
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: '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])
// done meta polling (unverändert)
useEffect(() => {
let cancelled = false
let t: number | undefined
const loadDoneMeta = async () => {
try {
const res = await fetch('/api/record/done/meta', { cache: 'no-store' })
if (!res.ok) return
const meta = (await res.json()) as { count?: number }
if (!cancelled) {
setDoneCount(meta.count ?? 0)
setLastHeaderUpdateAtMs(Date.now())
}
} catch {
// ignore
} finally {
if (!cancelled) {
const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneMeta, ms)
}
}
}
const onVis = () => {
if (!document.hidden) void loadDoneMeta()
}
document.addEventListener('visibilitychange', onVis)
void loadDoneMeta()
return () => {
cancelled = true
if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [])
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
if (donePage > maxPage) setDonePage(maxPage)
}, [doneCount, donePage])
// jobs SSE / polling (unverändert)
useEffect(() => {
let cancelled = false
let es: EventSource | null = null
let fallbackTimer: number | null = null
let inFlight = false
const applyList = (list: any) => {
const arr = Array.isArray(list) ? (list as RecordJob[]) : []
if (cancelled) return
const prev = jobsRef.current
const prevById = new Map(prev.map((j) => [j.id, j.status]))
const endedNow = arr.some((j) => {
const ps = prevById.get(j.id)
return ps && ps !== j.status && (j.status === 'finished' || j.status === 'stopped')
})
setJobs(arr)
jobsRef.current = arr
setLastHeaderUpdateAtMs(Date.now())
if (endedNow) bumpAssetsTwice()
setPlayerJob((prevJob) => {
if (!prevJob) return prevJob
const updated = arr.find((j) => j.id === prevJob.id)
if (updated) return updated
return prevJob.status === 'running' ? null : prevJob
})
}
const loadOnce = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>('/api/record/list')
applyList(list)
} catch {
// ignore
} finally {
inFlight = false
}
}
const startFallbackPolling = () => {
if (fallbackTimer) return
fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000)
}
void loadOnce()
es = new EventSource('/api/record/stream')
const onJobs = (ev: MessageEvent) => {
try {
applyList(JSON.parse(ev.data))
} catch {}
}
es.addEventListener('jobs', onJobs as any)
es.onerror = () => startFallbackPolling()
const onVis = () => {
if (!document.hidden) void loadOnce()
}
document.addEventListener('visibilitychange', onVis)
window.addEventListener('hover', onVis)
return () => {
cancelled = true
if (fallbackTimer) window.clearInterval(fallbackTimer)
document.removeEventListener('visibilitychange', onVis)
window.removeEventListener('hover', onVis)
es?.removeEventListener('jobs', onJobs as any)
es?.close()
es = null
}
}, [bumpAssetsTwice])
useEffect(() => {
if (selectedTab !== 'finished') return
let cancelled = false
let inFlight = false
const loadDone = async () => {
if (cancelled || inFlight) return
inFlight = true
try {
const list = await apiJSON<RecordJob[]>(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setDoneJobs([])
} finally {
inFlight = false
}
}
loadDone()
const baseMs = 20000
const tickMs = document.hidden ? 60000 : baseMs
const t = window.setInterval(loadDone, tickMs)
const onVis = () => {
if (!document.hidden) void loadDone()
}
document.addEventListener('visibilitychange', onVis)
return () => {
cancelled = true
window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, donePage, doneSort])
const refreshDoneNow = useCallback(
async (preferPage?: number) => {
try {
const meta = await apiJSON<{ count?: number }>('/api/record/done/meta', { cache: 'no-store' as any })
const countRaw = typeof meta?.count === 'number' ? meta.count : 0
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target)
const list = await apiJSON<RecordJob[]>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
setDoneJobs(Array.isArray(list) ? list : [])
} catch {
// ignore
}
},
[donePage, doneSort]
)
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))
}
}
// ---- 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 handleDeleteJob = 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/delete?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('Löschen fehlgeschlagen', e?.message ?? String(e))
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
}
}, [])
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)
if (selectedTab !== 'finished') void refreshDoneNow()
} catch (e: any) {
window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
notify.error('Keep fehlgeschlagen', e?.message ?? String(e))
return
}
},
[selectedTab, refreshDoneNow]
)
const handleToggleHot = useCallback(async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) return
try {
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
{ method: 'POST' }
)
const newOutput = replaceBasename(job.output || '', res.newFile)
setPlayerJob((prev) => (prev ? { ...prev, output: newOutput } : prev))
setDoneJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
)
setJobs((prev) =>
prev.map((j) => (baseName(j.output || '') === file ? { ...j, output: replaceBasename(j.output || '', res.newFile) } : j))
)
} 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 url = extractFirstUrl(text)
if (!url) return
if (!getProviderFromNormalizedUrl(url)) return
if (url === lastClipboardUrlRef.current) return
lastClipboardUrlRef.current = url
if (autoAddEnabled) setSourceUrl(url)
if (autoStartEnabled) {
if (busyRef.current) {
pendingStartUrlRef.current = url
} else {
pendingStartUrlRef.current = null
await startUrl(url)
}
}
} 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(() => {
if (busy) return
if (!autoStartEnabled) return
const pending = pendingStartUrlRef.current
if (!pending) return
pendingStartUrlRef.current = null
void startUrl(pending)
}, [busy, 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: 12000,
onData: (data: ChaturbateOnlineResponse) => {
void (async () => {
if (!data?.enabled) {
setCbOnlineByKeyLower({})
cbOnlineByKeyLowerRef.current = {}
setOnlineStoreKeysLower({})
setPendingWatchedRooms([])
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
// 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
}
setOnlineStoreKeysLower(nextOnlineStore)
// 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
const ok = await startUrl(url, { silent: true })
if (ok) {
// ✅ State + Ref gleichzeitig “synchron” löschen
setPendingAutoStartByKey((prev) => {
const copy = { ...(prev || {}) }
delete copy[kLower]
pendingAutoStartByKeyRef.current = copy
return copy
})
}
}
setLastHeaderUpdateAtMs(Date.now())
})()
},
})
return () => stop()
}, [])
return (
<div className="min-h-screen 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="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">
<PerformanceMonitor mode="inline" className="flex-1" />
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
className="px-3 shrink-0"
>
Cookies
</Button>
</div>
</div>
</div>
</div>
<div className="hidden sm:flex items-center gap-2 h-full">
<PerformanceMonitor mode="inline" />
<Button variant="secondary" onClick={() => setCookieModalOpen(true)} className="h-9 px-3">
Cookies
</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}
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={handleDeleteJob}
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)
}}
onRefreshDone={refreshDoneNow}
/>
) : null}
{selectedTab === 'models' ? <ModelsTab /> : 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(() => {})
}}
/>
{playerJob ? (
<Player
job={playerJob}
modelKey={playerModelKey ?? undefined}
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}
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
/>
</div>
</div>
)
}