2063 lines
69 KiB
TypeScript
2063 lines
69 KiB
TypeScript
// 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>
|
||
)
|
||
}
|