4988 lines
153 KiB
TypeScript
4988 lines
153 KiB
TypeScript
// frontend\src\App.tsx
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'
|
||
import './App.css'
|
||
import Button from './components/ui/Button'
|
||
import CookieModal from './components/ui/CookieModal'
|
||
import Tabs, { type TabItem } from './components/ui/Tabs'
|
||
import Settings from './components/ui/Settings'
|
||
import FinishedDownloads from './components/ui/FinishedDownloads'
|
||
import Player from './components/ui/Player'
|
||
import type { RecordJob, JobEvent, RecorderSettingsState, TaskStateEvent, AnalysisProgressEvent, BackgroundSplitProgress } from './types'
|
||
import Downloads from './components/ui/Downloads'
|
||
import ModelsTab from './components/ui/ModelsTab'
|
||
import ProgressBar from './components/ui/ProgressBar'
|
||
import ModelDetails from './components/ui/ModelDetails'
|
||
import {
|
||
SignalIcon,
|
||
HeartIcon,
|
||
StarIcon,
|
||
EyeIcon,
|
||
ArrowDownTrayIcon,
|
||
ArchiveBoxIcon,
|
||
UserGroupIcon,
|
||
Squares2X2Icon,
|
||
Cog6ToothIcon,
|
||
BeakerIcon,
|
||
CircleStackIcon,
|
||
ArrowRightOnRectangleIcon,
|
||
PlayIcon,
|
||
} from '@heroicons/react/24/solid'
|
||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||
import { useNotify } from './components/ui/notify'
|
||
import CategoriesTab from './components/ui/CategoriesTab'
|
||
import LoginPage from './components/ui/LoginPage'
|
||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||
import TextInput from './components/ui/TextInput'
|
||
import LastUpdatedText from './components/ui/LastUpdatedText'
|
||
import TrainingTab from './components/ui/TrainingTab'
|
||
|
||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||
const MODELS_OVERVIEW_DEBOUNCE_MS = 750
|
||
const MODELS_OVERVIEW_COOLDOWN_MS = 5000
|
||
|
||
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>
|
||
}
|
||
|
||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||
recordDir: 'records',
|
||
doneDir: 'records/done',
|
||
ffmpegPath: '',
|
||
autoAddToDownloadList: false,
|
||
autoStartAddedDownloads: false,
|
||
enableConcurrentDownloadsLimit: false,
|
||
maxConcurrentDownloads: 20,
|
||
useProviderApis: false,
|
||
useChaturbateApi: false,
|
||
useMyFreeCamsApi: false,
|
||
autoDeleteSmallDownloads: false,
|
||
autoDeleteSmallDownloadsBelowMB: 200,
|
||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||
blurPreviews: false,
|
||
teaserPlayback: 'hover',
|
||
teaserAudio: false,
|
||
lowDiskPauseBelowGB: 5,
|
||
enableNotifications: true,
|
||
|
||
generateAssetsMeta: true,
|
||
generateAssetsThumb: true,
|
||
generateAssetsTeaser: true,
|
||
generateAssetsSprites: true,
|
||
generateAssetsAnalyze: true,
|
||
enrichPostworkEnabled: true,
|
||
trainingRecognitionEnabled: true,
|
||
trainingDetectorEpochs: 60,
|
||
trainingPerformanceMode: 'auto',
|
||
trainingPowerSaveMode: false,
|
||
trainingCpuThreads: 0,
|
||
trainingWorkers: 2,
|
||
trainingYoloBatchSize: 0,
|
||
trainingLowPriority: false,
|
||
trainingVideoMAEEnabled: true,
|
||
}
|
||
|
||
type StoredModel = {
|
||
id: string
|
||
input: string
|
||
host?: string
|
||
modelKey: string
|
||
|
||
watching: boolean
|
||
favorite?: boolean
|
||
liked?: boolean | null
|
||
tags?: string
|
||
|
||
isUrl?: boolean
|
||
path?: string
|
||
|
||
roomStatus?: string
|
||
isOnline?: boolean
|
||
chatRoomUrl?: string
|
||
imageUrl?: string
|
||
|
||
gender?: string | null
|
||
sex?: string | null
|
||
subgender?: string | null
|
||
country?: string | null
|
||
|
||
lastOnlineAt?: string
|
||
lastOfflineAt?: string
|
||
lastRoomSyncAt?: string
|
||
}
|
||
|
||
type PendingWatchedRoom = {
|
||
id: string
|
||
modelKey: string
|
||
url: string
|
||
currentShow: string // private/hidden/away/public/unknown
|
||
imageUrl?: string
|
||
}
|
||
|
||
type ChaturbateOnlineRoomItem = {
|
||
username?: string
|
||
current_show?: string
|
||
chat_room_url?: string
|
||
image_url?: string
|
||
}
|
||
|
||
type ChaturbateOnlineResponse = {
|
||
enabled?: boolean
|
||
fetchedAt?: string
|
||
lastAttempt?: string
|
||
count?: number
|
||
total?: number
|
||
lastError?: string
|
||
rooms?: ChaturbateOnlineRoomItem[]
|
||
}
|
||
|
||
type AutostartState = {
|
||
paused?: boolean
|
||
pausedByUser?: boolean
|
||
pausedByDisk?: boolean
|
||
}
|
||
|
||
type PendingAutoStartMode = 'wait_public' | 'probe_retry'
|
||
|
||
type PendingAutoStartSource = 'manual' | 'watched'
|
||
|
||
type PendingAutoStartItem = {
|
||
modelKey: string
|
||
url: string
|
||
mode?: PendingAutoStartMode
|
||
nextProbeAtMs?: number
|
||
currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
|
||
imageUrl?: string
|
||
source?: PendingAutoStartSource
|
||
}
|
||
|
||
type PendingAutoStartResponse = {
|
||
items?: PendingAutoStartItem[]
|
||
}
|
||
|
||
function mergeStoredModel(
|
||
prev?: StoredModel | null,
|
||
incoming?: StoredModel | null
|
||
): StoredModel {
|
||
if (!prev && !incoming) {
|
||
return {
|
||
id: '',
|
||
input: '',
|
||
modelKey: '',
|
||
watching: false,
|
||
}
|
||
}
|
||
|
||
if (!prev) return incoming as StoredModel
|
||
if (!incoming) return prev
|
||
|
||
return {
|
||
...prev,
|
||
...incoming,
|
||
|
||
id: incoming.id || prev.id,
|
||
input: incoming.input || prev.input,
|
||
host: incoming.host || prev.host,
|
||
modelKey: incoming.modelKey || prev.modelKey,
|
||
|
||
watching: incoming.watching ?? prev.watching ?? false,
|
||
favorite: incoming.favorite ?? prev.favorite,
|
||
liked: incoming.liked ?? prev.liked,
|
||
tags: incoming.tags ?? prev.tags,
|
||
|
||
isUrl: incoming.isUrl ?? prev.isUrl,
|
||
path: incoming.path || prev.path,
|
||
|
||
roomStatus: incoming.roomStatus ?? prev.roomStatus,
|
||
isOnline: incoming.isOnline ?? prev.isOnline,
|
||
chatRoomUrl: incoming.chatRoomUrl || prev.chatRoomUrl,
|
||
imageUrl: incoming.imageUrl || prev.imageUrl,
|
||
|
||
gender: incoming.gender ?? prev.gender ?? null,
|
||
sex: incoming.sex ?? prev.sex ?? null,
|
||
subgender: incoming.subgender ?? prev.subgender ?? null,
|
||
|
||
lastOnlineAt: incoming.lastOnlineAt ?? prev.lastOnlineAt,
|
||
lastOfflineAt: incoming.lastOfflineAt ?? prev.lastOfflineAt,
|
||
lastRoomSyncAt: incoming.lastRoomSyncAt ?? prev.lastRoomSyncAt,
|
||
}
|
||
}
|
||
|
||
function storedModelShallowEqual(a?: StoredModel | null, b?: StoredModel | null) {
|
||
if (a === b) return true
|
||
if (!a || !b) return false
|
||
|
||
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
||
for (const key of keys) {
|
||
if (!Object.is((a as any)[key], (b as any)[key])) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||
const s = String(v ?? '').trim().toLowerCase()
|
||
|
||
switch (s) {
|
||
case 'public':
|
||
return 'public'
|
||
case 'private':
|
||
return 'private'
|
||
case 'hidden':
|
||
return 'hidden'
|
||
case 'away':
|
||
return 'away'
|
||
case 'offline':
|
||
return 'offline'
|
||
default:
|
||
return 'unknown'
|
||
}
|
||
}
|
||
|
||
function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||
return normalizePendingShow(v)
|
||
}
|
||
|
||
function roomStatusLabelDE(v: unknown): string {
|
||
switch (normalizeRoomStatusText(v)) {
|
||
case 'public':
|
||
return 'live'
|
||
case 'private':
|
||
return 'privat'
|
||
case 'hidden':
|
||
return 'hidden'
|
||
case 'away':
|
||
return 'away'
|
||
case 'offline':
|
||
return 'offline'
|
||
default:
|
||
return 'unbekannt'
|
||
}
|
||
}
|
||
|
||
function normalizeHttpUrl(raw: string): string | null {
|
||
let v = (raw ?? '').trim()
|
||
if (!v) return null
|
||
|
||
// häufige Copy/Paste-Randzeichen entfernen
|
||
v = v.replace(/^[("'[{<]+/, '').replace(/[)"'\]}>.,;:]+$/, '')
|
||
|
||
// ohne Scheme -> https://
|
||
if (!/^https?:\/\//i.test(v)) v = `https://${v}`
|
||
|
||
try {
|
||
const u = new URL(v)
|
||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
|
||
return u.toString()
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function extractFirstUrl(text: string): string | null {
|
||
const t = (text ?? '').trim()
|
||
if (!t) return null
|
||
|
||
for (const token of t.split(/\s+/g)) {
|
||
const url = normalizeHttpUrl(token)
|
||
if (url) return url
|
||
}
|
||
return null
|
||
}
|
||
|
||
async function canReadClipboardWithoutPrompt(): Promise<boolean> {
|
||
if (!navigator.clipboard?.readText) return false
|
||
|
||
const permissions = (navigator as Navigator & {
|
||
permissions?: {
|
||
query?: (descriptor: { name: string }) => Promise<{ state?: string }>
|
||
}
|
||
}).permissions
|
||
|
||
if (!permissions?.query) return false
|
||
|
||
try {
|
||
const status = await permissions.query({ name: 'clipboard-read' })
|
||
return status.state === 'granted'
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
type Provider = 'chaturbate' | 'mfc'
|
||
|
||
function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
|
||
try {
|
||
const host = new URL(normUrl).hostname.replace(/^www\./i, '').toLowerCase()
|
||
if (host === 'chaturbate.com' || host.endsWith('.chaturbate.com')) return 'chaturbate'
|
||
if (host === 'myfreecams.com' || host.endsWith('.myfreecams.com')) return 'mfc'
|
||
return null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
function chaturbateUserFromUrl(normUrl: string): string {
|
||
try {
|
||
const u = new URL(normUrl)
|
||
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||
if (host !== 'chaturbate.com' && !host.endsWith('.chaturbate.com')) return ''
|
||
|
||
// https://chaturbate.com/<name>/...
|
||
const parts = u.pathname.split('/').filter(Boolean)
|
||
return parts[0] ? decodeURIComponent(parts[0]).trim() : ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Macht aus "beliebigen" Provider-URLs eine EINDEUTIGE Standardform.
|
||
* -> wichtig für dedupe (Queue, alreadyRunning), Clipboard, Pending-Maps.
|
||
*/
|
||
function canonicalizeProviderUrl(normUrl: string): string {
|
||
const provider = getProviderFromNormalizedUrl(normUrl)
|
||
if (!provider) return normUrl
|
||
|
||
if (provider === 'chaturbate') {
|
||
const name = chaturbateUserFromUrl(normUrl)
|
||
return name ? `https://chaturbate.com/${encodeURIComponent(name)}/` : normUrl
|
||
}
|
||
|
||
// provider === 'mfc'
|
||
const name = mfcUserFromUrl(normUrl)
|
||
// Standardisiere auf EIN Format (hier: #<name>)
|
||
return name ? `https://www.myfreecams.com/#${encodeURIComponent(name)}` : normUrl
|
||
}
|
||
|
||
/** Gibt den "ModelKey" aus einer URL zurück (lowercased) – für beide Provider */
|
||
function providerKeyLowerFromUrl(normUrl: string): string {
|
||
const provider = getProviderFromNormalizedUrl(normUrl)
|
||
if (!provider) return ''
|
||
const raw = provider === 'chaturbate' ? chaturbateUserFromUrl(normUrl) : mfcUserFromUrl(normUrl)
|
||
return (raw || '').trim().toLowerCase()
|
||
}
|
||
|
||
|
||
function mfcUserFromUrl(normUrl: string): string {
|
||
try {
|
||
const u = new URL(normUrl)
|
||
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||
|
||
// nur MFC
|
||
if (host !== 'myfreecams.com' && !host.endsWith('.myfreecams.com')) return ''
|
||
|
||
// typische MFC Profile-URLs:
|
||
// https://www.myfreecams.com/#<name>
|
||
// https://www.myfreecams.com/#/models/<name>
|
||
// https://www.myfreecams.com/<name> (seltener)
|
||
const hash = (u.hash || '').replace(/^#\/?/, '') // "#/models/foo" -> "models/foo"
|
||
if (hash) {
|
||
const parts = hash.split('/').filter(Boolean)
|
||
const last = parts[parts.length - 1] || ''
|
||
if (last) return decodeURIComponent(last).trim()
|
||
}
|
||
|
||
const parts = u.pathname.split('/').filter(Boolean)
|
||
const last = parts[parts.length - 1] || ''
|
||
return last ? decodeURIComponent(last).trim() : ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||
|
||
function videoSrcFromJob(job: RecordJob | null): string {
|
||
if (!job) return ''
|
||
const file = baseName(job.output || '')
|
||
if (!file) return ''
|
||
return `/api/record/video?file=${encodeURIComponent(file)}`
|
||
}
|
||
|
||
function replaceBasename(fullPath: string, newBase: string) {
|
||
const norm = (fullPath || '').replaceAll('\\', '/')
|
||
const parts = norm.split('/')
|
||
parts[parts.length - 1] = newBase
|
||
return parts.join('/')
|
||
}
|
||
|
||
function stripHotPrefix(name: string) {
|
||
return name.startsWith('HOT ') ? name.slice(4) : name
|
||
}
|
||
|
||
// wie backend models.go
|
||
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
|
||
|
||
function chaturbateModelKeyFromJob(job: RecordJob): string {
|
||
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
if (direct) return direct
|
||
|
||
const raw = String((job as any)?.sourceUrl ?? '').trim()
|
||
const norm0 = normalizeHttpUrl(raw)
|
||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||
|
||
const keyFromUrl = providerKeyLowerFromUrl(norm)
|
||
if (keyFromUrl) return keyFromUrl
|
||
|
||
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
return keyFromFile
|
||
}
|
||
|
||
function modelKeyFromFilename(fileOrPath: string): string | null {
|
||
const file = stripHotPrefix(baseName(fileOrPath))
|
||
const base = file.replace(/\.[^.]+$/, '') // ext weg
|
||
|
||
const m = base.match(reModel)
|
||
if (m?.[1]?.trim()) return m[1].trim()
|
||
|
||
const i = base.lastIndexOf('_')
|
||
if (i > 0) return base.slice(0, i)
|
||
|
||
return base ? base : null
|
||
}
|
||
|
||
function modelEventKeyFromJob(job: RecordJob): string {
|
||
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
if (direct) return direct
|
||
|
||
const raw = String((job as any)?.sourceUrl ?? '').trim()
|
||
const norm0 = normalizeHttpUrl(raw)
|
||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||
|
||
const keyFromUrl = providerKeyLowerFromUrl(norm)
|
||
if (keyFromUrl) return keyFromUrl
|
||
|
||
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
return keyFromFile
|
||
}
|
||
|
||
function modelEventKeyFromDoneJob(job: RecordJob): string {
|
||
const { modelKey } = modelKeyAndHostFromJob(job)
|
||
return String(modelKey || '').trim().toLowerCase()
|
||
}
|
||
|
||
function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
|
||
const direct = String((job as any)?.model ?? (job as any)?.modelKey ?? '')
|
||
.trim()
|
||
.toLowerCase()
|
||
|
||
let host = ''
|
||
try {
|
||
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
|
||
const u0 = extractFirstUrl(raw)
|
||
if (u0) {
|
||
const u = new URL(u0)
|
||
host = u.hostname.replace(/^www\./i, '').toLowerCase()
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
|
||
if (direct) {
|
||
return { modelKey: direct, host }
|
||
}
|
||
|
||
const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '')
|
||
.trim()
|
||
.toLowerCase()
|
||
|
||
if (keyFromFile) {
|
||
return { modelKey: keyFromFile, host }
|
||
}
|
||
|
||
const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
|
||
const u0 = extractFirstUrl(raw)
|
||
if (u0) {
|
||
const norm0 = normalizeHttpUrl(u0)
|
||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||
const keyFromUrl = providerKeyLowerFromUrl(norm)
|
||
if (keyFromUrl) {
|
||
return { modelKey: keyFromUrl, host }
|
||
}
|
||
}
|
||
|
||
return { modelKey: '', host }
|
||
}
|
||
|
||
const isTerminalJobStatusForCount = (status?: unknown) => {
|
||
const s = String(status ?? '').trim().toLowerCase()
|
||
return (
|
||
s === 'stopped' ||
|
||
s === 'finished' ||
|
||
s === 'failed' ||
|
||
s === 'done' ||
|
||
s === 'completed' ||
|
||
s === 'canceled' ||
|
||
s === 'cancelled'
|
||
)
|
||
}
|
||
|
||
const isPostworkJobForCount = (job: RecordJob): boolean => {
|
||
const anyJ = job as any
|
||
const phase = String(anyJ.phase ?? '').trim()
|
||
const pw = anyJ.postWork
|
||
const pwKey = String(anyJ.postWorkKey ?? '').trim()
|
||
|
||
if (pwKey) return true
|
||
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
|
||
if (job.endedAt && phase) return true
|
||
if (phase.toLowerCase() === 'postwork') return true
|
||
|
||
return false
|
||
}
|
||
|
||
function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
|
||
const incomingId = String((incoming as any)?.id ?? '').trim()
|
||
if (!incomingId) return list
|
||
|
||
const byId = new Map<string, RecordJob>()
|
||
|
||
for (const j of list) {
|
||
const id = String((j as any)?.id ?? '').trim()
|
||
if (!id) continue
|
||
byId.set(id, j)
|
||
}
|
||
|
||
byId.set(incomingId, {
|
||
...(byId.get(incomingId) ?? ({} as RecordJob)),
|
||
...incoming,
|
||
})
|
||
|
||
return Array.from(byId.values()).sort((a, b) => {
|
||
const aMs =
|
||
Number((a as any)?.startedAtMs ?? 0) ||
|
||
(a?.startedAt ? Date.parse(a.startedAt) || 0 : 0)
|
||
|
||
const bMs =
|
||
Number((b as any)?.startedAtMs ?? 0) ||
|
||
(b?.startedAt ? Date.parse(b.startedAt) || 0 : 0)
|
||
|
||
return bMs - aMs
|
||
})
|
||
}
|
||
|
||
function TrainingBeakerIcon(props: React.ComponentProps<typeof BeakerIcon>) {
|
||
const { className } = props
|
||
|
||
return (
|
||
<span
|
||
className={[
|
||
'training-beaker-icon relative inline-block overflow-visible',
|
||
className ?? '',
|
||
].join(' ')}
|
||
aria-hidden="true"
|
||
>
|
||
<BeakerIcon className="block h-full w-full" />
|
||
|
||
<span className="training-beaker-bubble training-beaker-bubble-1" />
|
||
<span className="training-beaker-bubble training-beaker-bubble-2" />
|
||
<span className="training-beaker-bubble training-beaker-bubble-3" />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function normalizeTitleProgress(value: unknown): number | null {
|
||
const n = Number(value)
|
||
|
||
if (!Number.isFinite(n)) return null
|
||
|
||
// Backend-Training kommt als 0..100.
|
||
// Analysis-Progress kommt als 0..1, wird aber für den Title nicht mehr benutzt.
|
||
const normalized = n > 1 ? n / 100 : n
|
||
|
||
return Math.max(0, Math.min(1, normalized))
|
||
}
|
||
|
||
export default function App() {
|
||
const [trainingTabRunning, setTrainingTabRunning] = useState(false)
|
||
const [trainingTitleProgress, setTrainingTitleProgress] = useState<number | null>(null)
|
||
const [trainingImageExpanded, setTrainingImageExpanded] = useState(false)
|
||
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
|
||
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
|
||
|
||
useEffect(() => {
|
||
const baseTitle = 'Recorder'
|
||
|
||
if (trainingTabRunning) {
|
||
const progress =
|
||
typeof trainingTitleProgress === 'number' && Number.isFinite(trainingTitleProgress)
|
||
? ` ${Math.round(Math.max(0, Math.min(1, trainingTitleProgress)) * 100)}%`
|
||
: ''
|
||
|
||
document.title = `${baseTitle} · Training${progress}`
|
||
return
|
||
}
|
||
|
||
document.title = baseTitle
|
||
}, [trainingTabRunning, trainingTitleProgress])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const t of Object.values(splitProgressClearTimersRef.current)) {
|
||
window.clearTimeout(t)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
const clearSplitProgressTimer = useCallback((file: string) => {
|
||
const t = splitProgressClearTimersRef.current[file]
|
||
if (t != null) {
|
||
window.clearTimeout(t)
|
||
delete splitProgressClearTimersRef.current[file]
|
||
}
|
||
}, [])
|
||
|
||
const scheduleSplitProgressCleanup = useCallback((file: string, delayMs: number) => {
|
||
clearSplitProgressTimer(file)
|
||
|
||
splitProgressClearTimersRef.current[file] = window.setTimeout(() => {
|
||
setSplitProgressByFile((prev) => {
|
||
if (!(file in prev)) return prev
|
||
const next = { ...prev }
|
||
delete next[file]
|
||
return next
|
||
})
|
||
delete splitProgressClearTimersRef.current[file]
|
||
}, delayMs)
|
||
}, [clearSplitProgressTimer])
|
||
|
||
const pushSplitProgress = useCallback((
|
||
file: string,
|
||
progress: Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
|
||
) => {
|
||
clearSplitProgressTimer(file)
|
||
|
||
setSplitProgressByFile((prev) => ({
|
||
...prev,
|
||
[file]: {
|
||
sourceFile: file,
|
||
startedAtMs: prev[file]?.startedAtMs ?? Date.now(),
|
||
...progress,
|
||
},
|
||
}))
|
||
|
||
if (progress.phase === 'done') {
|
||
scheduleSplitProgressCleanup(file, 8000)
|
||
} else if (progress.phase === 'error') {
|
||
scheduleSplitProgressCleanup(file, 15000)
|
||
}
|
||
}, [clearSplitProgressTimer, scheduleSplitProgressCleanup])
|
||
|
||
const [authChecked, setAuthChecked] = useState(false)
|
||
const [authed, setAuthed] = useState(false)
|
||
const sourceUrlInputRef = useRef<HTMLInputElement | null>(null)
|
||
|
||
const notify = useNotify()
|
||
|
||
const notifyRef = useRef(notify)
|
||
|
||
const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
|
||
const roomStatusBaselineReadyRef = useRef(false)
|
||
|
||
const pendingJobEventsRef = useRef<JobEvent[]>([])
|
||
const pendingJobEventsTimerRef = useRef<number | null>(null)
|
||
|
||
// ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
|
||
const cookieProblemLastAtRef = useRef(0)
|
||
|
||
const donePrefetchRef = useRef<DonePrefetch | null>(null)
|
||
const donePrefetchInFlightRef = useRef(false)
|
||
|
||
const doneCountInFlightRef = useRef(false)
|
||
const doneCountLastAtRef = useRef(0)
|
||
const pendingLoadInFlightRef = useRef(false)
|
||
const pendingLoadLastAtRef = useRef(0)
|
||
|
||
const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
|
||
const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)
|
||
|
||
const [detailsModelKey, setDetailsModelKey] = useState<string | null>(null)
|
||
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
const [cookieModalOpen, setCookieModalOpen] = useState(false)
|
||
const [cookies, setCookies] = useState<Record<string, string>>({})
|
||
const [cookiesLoaded, setCookiesLoaded] = useState(false)
|
||
const [selectedTab, setSelectedTab] = useState('running')
|
||
const [settingsTaskRunning, setSettingsTaskRunning] = useState(false)
|
||
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
|
||
const [playerExpanded, setPlayerExpanded] = useState(false)
|
||
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
|
||
const [splitJob, setSplitJob] = useState<RecordJob | null>(null)
|
||
const [splitModalOpen, setSplitModalOpen] = useState(false)
|
||
const [splitModalKey, setSplitModalKey] = useState(0)
|
||
|
||
const [assetNonce, setAssetNonce] = useState(0)
|
||
const [assetNonceByFile, setAssetNonceByFile] = useState<Record<string, number>>({})
|
||
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
|
||
const bumpAssetForFile = useCallback((fileOrPath: string) => {
|
||
const file = baseName(fileOrPath)
|
||
if (!file) {
|
||
bumpAssets()
|
||
return
|
||
}
|
||
|
||
const plainFile = stripHotPrefix(file)
|
||
|
||
setAssetNonceByFile((prev) => {
|
||
const next = { ...prev }
|
||
next[file] = (next[file] ?? 0) + 1
|
||
|
||
if (plainFile && plainFile !== file) {
|
||
next[plainFile] = (next[plainFile] ?? 0) + 1
|
||
}
|
||
|
||
return next
|
||
})
|
||
}, [bumpAssets])
|
||
|
||
const [recSettings, setRecSettings] = useState<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS)
|
||
|
||
const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
|
||
const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)
|
||
|
||
const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
|
||
const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
|
||
const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
|
||
const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})
|
||
|
||
const [pendingAutoStartSourceByKey, setPendingAutoStartSourceByKey] = useState<Record<string, PendingAutoStartSource>>({})
|
||
|
||
const pendingAutoStartSourceByKeyRef = useRef(pendingAutoStartSourceByKey)
|
||
useEffect(() => {
|
||
pendingAutoStartSourceByKeyRef.current = pendingAutoStartSourceByKey
|
||
}, [pendingAutoStartSourceByKey])
|
||
|
||
const pendingAutoStartModeByKeyRef = useRef(pendingAutoStartModeByKey)
|
||
useEffect(() => {
|
||
pendingAutoStartModeByKeyRef.current = pendingAutoStartModeByKey
|
||
}, [pendingAutoStartModeByKey])
|
||
|
||
const pendingBlindRetryAtByKeyRef = useRef(pendingBlindRetryAtByKey)
|
||
useEffect(() => {
|
||
pendingBlindRetryAtByKeyRef.current = pendingBlindRetryAtByKey
|
||
}, [pendingBlindRetryAtByKey])
|
||
|
||
// "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
|
||
const busyRef = useRef(false)
|
||
const cookiesRef = useRef<Record<string, string>>({})
|
||
const jobsRef = useRef<RecordJob[]>([])
|
||
|
||
// ✅ "Job gestartet" Toast: dedupe (auch gegen SSE/polling) + initial-load suppression
|
||
const startedToastByJobIdRef = useRef<Record<string, true>>({})
|
||
const jobsInitDoneRef = useRef(false)
|
||
|
||
const lower = (s: string) => (s || '').toLowerCase().trim()
|
||
|
||
const eventSourceRef = useRef<EventSource | null>(null)
|
||
const modelEventNamesRef = useRef<Set<string>>(new Set())
|
||
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
|
||
|
||
const lastSeenAtByJobIdRef = useRef<Record<string, number>>({})
|
||
const MISSING_JOB_GRACE_MS = 20_000
|
||
|
||
|
||
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
|
||
const [sourceUrl, setSourceUrl] = useState('')
|
||
const [jobs, setJobs] = useState<RecordJob[]>([])
|
||
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
|
||
const [donePage, setDonePage] = useState(1)
|
||
const [doneCount, setDoneCount] = useState<number>(0)
|
||
const [modelsCount, setModelsCount] = useState(0)
|
||
|
||
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
|
||
|
||
const [cbOnlineFetchedAt, setCbOnlineFetchedAt] = useState<string>('')
|
||
|
||
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
|
||
|
||
const INCLUDE_KEEP_KEY = 'finishedDownloads_includeKeep_v2'
|
||
|
||
const [includeKeep, setIncludeKeep] = useState<boolean>(() => {
|
||
try {
|
||
const raw = window.localStorage.getItem(INCLUDE_KEEP_KEY)
|
||
return raw === '1' || raw === 'true' || raw === 'yes'
|
||
} catch {
|
||
return false
|
||
}
|
||
})
|
||
|
||
const checkAuth = useCallback(async () => {
|
||
try {
|
||
const res = await apiJSON<{ authenticated?: boolean }>('/api/auth/me', { cache: 'no-store' as any })
|
||
setAuthed(Boolean(res?.authenticated))
|
||
} catch {
|
||
setAuthed(false)
|
||
} finally {
|
||
setAuthChecked(true)
|
||
}
|
||
}, [])
|
||
|
||
const logout = useCallback(async () => {
|
||
try {
|
||
// Backend löscht Session + Cookie (204)
|
||
await fetch('/api/auth/logout', { method: 'POST', cache: 'no-store' as any })
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
// UI/State reset
|
||
setAuthed(false)
|
||
setAuthChecked(true)
|
||
|
||
setError(null)
|
||
setBusy(false)
|
||
|
||
// optional: UI zurücksetzen, damit nach erneutem Login alles "clean" ist
|
||
setSelectedTab('running')
|
||
setPlayerJob(null)
|
||
setPlayerExpanded(false)
|
||
setDetailsModelKey(null)
|
||
|
||
// optional: Listen leeren (verhindert kurz sichtbare "alte" Daten beim Login-Screen)
|
||
setJobs([])
|
||
setDoneJobs([])
|
||
setDoneCount(0)
|
||
setDonePage(1)
|
||
|
||
setSplitJob(null)
|
||
setSplitModalOpen(false)
|
||
|
||
setModelsByKey({})
|
||
setModelsCount(0)
|
||
|
||
startedToastByJobIdRef.current = {}
|
||
jobsInitDoneRef.current = false
|
||
|
||
setPendingWatchedRooms([])
|
||
setPendingAutoStartByKey({})
|
||
setPendingAutoStartModeByKey({})
|
||
setPendingBlindRetryAtByKey({})
|
||
setPendingAutoStartSourceByKey({})
|
||
|
||
pendingAutoStartByKeyRef.current = {}
|
||
pendingAutoStartModeByKeyRef.current = {}
|
||
pendingBlindRetryAtByKeyRef.current = {}
|
||
pendingAutoStartSourceByKeyRef.current = {}
|
||
|
||
// optional: URL-Feld leeren
|
||
setSourceUrl('')
|
||
}
|
||
}, [])
|
||
|
||
const isCookieGateError = (msg: string) => {
|
||
const m = (msg || '').toLowerCase()
|
||
return (
|
||
m.includes('altersverifikationsseite erhalten') ||
|
||
m.includes('verify your age') ||
|
||
m.includes('schutzseite von cloudflare erhalten') ||
|
||
m.includes('just a moment') ||
|
||
m.includes('kein room-html')
|
||
)
|
||
}
|
||
|
||
const showMissingCookiesMessage = (opts?: { silent?: boolean }) => {
|
||
const silent = Boolean(opts?.silent)
|
||
|
||
const title = 'Cookies fehlen oder sind abgelaufen'
|
||
const body =
|
||
'Der Recorder hat statt des Room-HTML eine Schutz-/Altersverifikationsseite erhalten. ' +
|
||
'Bitte Cookies aktualisieren (bei Chaturbate z.B. cf_clearance + sessionId) und erneut starten.'
|
||
|
||
// Wenn Nutzer aktiv klickt: oben als Error-Box zeigen + Cookie-Modal anbieten
|
||
if (!silent) {
|
||
setError(`⚠️ ${title}. ${body}`)
|
||
// optional aber hilfreich: Modal direkt öffnen
|
||
setCookieModalOpen(true)
|
||
return
|
||
}
|
||
|
||
// Bei silent (Auto-Start / Queue): nur selten Toast
|
||
const now = Date.now()
|
||
if (now - cookieProblemLastAtRef.current > 15_000) {
|
||
cookieProblemLastAtRef.current = now
|
||
notifyRef.current?.error(title, body)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
notifyRef.current = notify
|
||
}, [notify])
|
||
|
||
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
|
||
const [showPerfMon, setShowPerfMon] = useState(false)
|
||
|
||
useEffect(() => {
|
||
const w = window as any
|
||
const id =
|
||
typeof w.requestIdleCallback === 'function'
|
||
? w.requestIdleCallback(() => setShowPerfMon(true), { timeout: 1500 })
|
||
: window.setTimeout(() => setShowPerfMon(true), 800)
|
||
|
||
return () => {
|
||
if (typeof w.cancelIdleCallback === 'function') w.cancelIdleCallback(id)
|
||
else window.clearTimeout(id)
|
||
}
|
||
}, [])
|
||
|
||
const BASE_DONE_PAGE_SIZE = 8
|
||
const [donePageSize, setDonePageSize] = useState<number>(BASE_DONE_PAGE_SIZE)
|
||
|
||
type DoneSortMode =
|
||
| 'completed_desc'
|
||
| 'completed_asc'
|
||
| 'file_asc'
|
||
| 'file_desc'
|
||
| 'duration_desc'
|
||
| 'duration_asc'
|
||
| 'size_desc'
|
||
| 'size_asc'
|
||
|
||
const DONE_SORT_KEY = 'finishedDownloads_sort'
|
||
const [doneSort, setDoneSort] = useState<DoneSortMode>(() => {
|
||
try {
|
||
const v = window.localStorage.getItem(DONE_SORT_KEY) as DoneSortMode | null
|
||
return v || 'completed_desc'
|
||
} catch {
|
||
return 'completed_desc'
|
||
}
|
||
})
|
||
|
||
type DonePrefetch = {
|
||
key: string
|
||
items: RecordJob[]
|
||
totalCount: number
|
||
ts: number
|
||
}
|
||
|
||
const makePrefetchKey = (
|
||
page: number,
|
||
sort: DoneSortMode,
|
||
pageSize: number,
|
||
keep: boolean
|
||
) => `${sort}::${pageSize}::${keep ? 1 : 0}::${page}`
|
||
|
||
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
|
||
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
|
||
return Math.max(1, Math.ceil(safeCount / pageSize))
|
||
}
|
||
|
||
const prefetchDonePage = useCallback(async (pageToFetch: number) => {
|
||
if (pageToFetch < 1) return
|
||
if (donePrefetchInFlightRef.current) return
|
||
|
||
const key = makePrefetchKey(pageToFetch, doneSort, donePageSize, includeKeep)
|
||
const cur = donePrefetchRef.current
|
||
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
|
||
// frisch genug
|
||
return
|
||
}
|
||
|
||
donePrefetchInFlightRef.current = true
|
||
try {
|
||
const res = await fetch(
|
||
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}&withCount=1${
|
||
includeKeep ? '&includeKeep=1' : ''
|
||
}`,
|
||
{ cache: 'no-store' as any }
|
||
)
|
||
if (!res.ok) return
|
||
const data = await res.json().catch(() => null)
|
||
|
||
const items = Array.isArray(data?.items)
|
||
? (data.items as RecordJob[])
|
||
: Array.isArray(data)
|
||
? (data as RecordJob[])
|
||
: []
|
||
|
||
const totalCountRaw = Number(data?.count ?? data?.totalCount ?? 0)
|
||
const totalCount =
|
||
Number.isFinite(totalCountRaw) && totalCountRaw >= 0
|
||
? totalCountRaw
|
||
: 0
|
||
|
||
donePrefetchRef.current = {
|
||
key,
|
||
items,
|
||
totalCount,
|
||
ts: Date.now(),
|
||
}
|
||
} finally {
|
||
donePrefetchInFlightRef.current = false
|
||
}
|
||
}, [doneSort, includeKeep, donePageSize])
|
||
|
||
const loadDoneCount = useCallback(async () => {
|
||
const now = Date.now()
|
||
|
||
// ✅ harte Dedupe-Schranke gegen Bursts
|
||
if (doneCountInFlightRef.current) return
|
||
if (now - doneCountLastAtRef.current < 500) return
|
||
|
||
doneCountInFlightRef.current = true
|
||
doneCountLastAtRef.current = now
|
||
|
||
try {
|
||
const res = await fetch(
|
||
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
|
||
{ cache: 'no-store' as any }
|
||
)
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
const countRaw = Number(data?.count ?? 0)
|
||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||
|
||
setDoneCount(count)
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
doneCountInFlightRef.current = false
|
||
}
|
||
}, [includeKeep])
|
||
|
||
const loadDonePage = useCallback(async (
|
||
pageToLoad = donePage,
|
||
sortToLoad = doneSort
|
||
) => {
|
||
try {
|
||
const key = makePrefetchKey(
|
||
pageToLoad,
|
||
sortToLoad,
|
||
donePageSize,
|
||
includeKeep
|
||
)
|
||
|
||
const prefetched = donePrefetchRef.current
|
||
|
||
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
|
||
setDoneJobs(prefetched.items)
|
||
|
||
const count = Number(prefetched.totalCount)
|
||
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
|
||
setDoneCount(count)
|
||
}
|
||
|
||
donePrefetchRef.current = null
|
||
|
||
const totalPages = getDoneTotalPages(count, donePageSize)
|
||
if (pageToLoad < totalPages) {
|
||
void prefetchDonePage(pageToLoad + 1)
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
const res = await fetch(
|
||
`/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}&withCount=1${
|
||
includeKeep ? '&includeKeep=1' : ''
|
||
}`,
|
||
{ cache: 'no-store' as any }
|
||
)
|
||
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
const items = Array.isArray(data?.items)
|
||
? (data.items as RecordJob[])
|
||
: []
|
||
|
||
setDoneJobs(items)
|
||
|
||
const countRaw = Number(data?.count ?? doneCount)
|
||
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
|
||
|
||
if (count !== doneCount) {
|
||
setDoneCount(count)
|
||
}
|
||
|
||
const totalPages = getDoneTotalPages(count, donePageSize)
|
||
if (pageToLoad < totalPages) {
|
||
void prefetchDonePage(pageToLoad + 1)
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
|
||
|
||
const loadDonePageRef = useRef(loadDonePage)
|
||
|
||
useEffect(() => {
|
||
loadDonePageRef.current = loadDonePage
|
||
}, [loadDonePage])
|
||
|
||
const doneRefreshTimerRef = useRef<number | null>(null)
|
||
const doneRefreshInFlightRef = useRef(false)
|
||
const doneRefreshQueuedRef = useRef(false)
|
||
|
||
const scheduleDoneRefresh = useCallback((delayMs = 450) => {
|
||
if (doneRefreshTimerRef.current != null) {
|
||
window.clearTimeout(doneRefreshTimerRef.current)
|
||
}
|
||
|
||
doneRefreshTimerRef.current = window.setTimeout(() => {
|
||
doneRefreshTimerRef.current = null
|
||
|
||
if (doneRefreshInFlightRef.current) {
|
||
doneRefreshQueuedRef.current = true
|
||
return
|
||
}
|
||
|
||
doneRefreshInFlightRef.current = true
|
||
|
||
void (async () => {
|
||
try {
|
||
donePrefetchRef.current = null
|
||
await loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||
await loadDoneCount()
|
||
} finally {
|
||
doneRefreshInFlightRef.current = false
|
||
|
||
if (doneRefreshQueuedRef.current) {
|
||
doneRefreshQueuedRef.current = false
|
||
scheduleDoneRefresh(300)
|
||
}
|
||
}
|
||
})()
|
||
}, Math.max(0, delayMs))
|
||
}, [loadDoneCount])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (doneRefreshTimerRef.current != null) {
|
||
window.clearTimeout(doneRefreshTimerRef.current)
|
||
doneRefreshTimerRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [includeKeep])
|
||
|
||
useEffect(() => {
|
||
if (!authed) {
|
||
setTrainingTabRunning(false)
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
|
||
const loadTrainingStatus = async () => {
|
||
try {
|
||
const res = await fetch('/api/training/status', { cache: 'no-store' as any })
|
||
const data = await res.json().catch(() => null)
|
||
|
||
if (cancelled || !res.ok || !data) return
|
||
|
||
setTrainingTabRunning(Boolean(data?.training?.running))
|
||
|
||
setTrainingTitleProgress(
|
||
normalizeTitleProgress(data?.training?.progress ?? data?.progress)
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
void loadTrainingStatus()
|
||
|
||
const onVisibilityChange = () => {
|
||
if (!document.hidden) {
|
||
void loadTrainingStatus()
|
||
}
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||
|
||
return () => {
|
||
cancelled = true
|
||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||
}
|
||
}, [authed])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const appWindow = window as any
|
||
appWindow.__nsfwappTrainingRunning = trainingTabRunning
|
||
window.dispatchEvent(
|
||
new CustomEvent('app:training-running', {
|
||
detail: { running: trainingTabRunning },
|
||
})
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [trainingTabRunning])
|
||
|
||
const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => {
|
||
const force = Boolean(opts?.force)
|
||
const now = Date.now()
|
||
|
||
if (pendingLoadInFlightRef.current) return
|
||
if (!force && now - pendingLoadLastAtRef.current < 1500) return
|
||
|
||
pendingLoadInFlightRef.current = true
|
||
pendingLoadLastAtRef.current = now
|
||
|
||
try {
|
||
const data = await apiJSON<PendingAutoStartResponse>('/api/pending-autostart', {
|
||
cache: 'no-store' as any,
|
||
})
|
||
|
||
const items = Array.isArray(data?.items) ? data.items : []
|
||
|
||
const nextByKey: Record<string, string> = {}
|
||
const nextModeByKey: Record<string, PendingAutoStartMode> = {}
|
||
const nextBlindRetryAtByKey: Record<string, number> = {}
|
||
const nextSourceByKey: Record<string, PendingAutoStartSource> = {}
|
||
|
||
for (const item of items) {
|
||
const keyLower = String(item?.modelKey ?? '').trim().toLowerCase()
|
||
const norm0 = normalizeHttpUrl(String(item?.url ?? ''))
|
||
const mode: PendingAutoStartMode =
|
||
String(item?.mode ?? '').trim() === 'probe_retry' ? 'probe_retry' : 'wait_public'
|
||
const source: PendingAutoStartSource =
|
||
String(item?.source ?? '').trim() === 'watched' ? 'watched' : 'manual'
|
||
|
||
if (!keyLower || !norm0) continue
|
||
|
||
const norm = canonicalizeProviderUrl(norm0)
|
||
|
||
nextByKey[keyLower] = norm
|
||
nextModeByKey[keyLower] = mode
|
||
nextSourceByKey[keyLower] = source
|
||
|
||
if (mode === 'probe_retry') {
|
||
const ts = Number(item?.nextProbeAtMs ?? 0)
|
||
nextBlindRetryAtByKey[keyLower] =
|
||
Number.isFinite(ts) && ts > 0 ? ts : Date.now() + 60_000
|
||
}
|
||
}
|
||
|
||
setPendingAutoStartByKey(nextByKey)
|
||
pendingAutoStartByKeyRef.current = nextByKey
|
||
|
||
setPendingAutoStartModeByKey(nextModeByKey)
|
||
pendingAutoStartModeByKeyRef.current = nextModeByKey
|
||
|
||
setPendingBlindRetryAtByKey(nextBlindRetryAtByKey)
|
||
pendingBlindRetryAtByKeyRef.current = nextBlindRetryAtByKey
|
||
|
||
setPendingAutoStartSourceByKey(nextSourceByKey)
|
||
pendingAutoStartSourceByKeyRef.current = nextSourceByKey
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
pendingLoadInFlightRef.current = false
|
||
}
|
||
}, [])
|
||
|
||
const loadJobs = useCallback(async () => {
|
||
try {
|
||
const res = await fetch('/api/record/list', { cache: 'no-store' as any })
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
const serverItems = Array.isArray(data)
|
||
? (data as RecordJob[])
|
||
: Array.isArray(data?.items)
|
||
? (data.items as RecordJob[])
|
||
: []
|
||
|
||
const now = Date.now()
|
||
|
||
for (const j of serverItems) {
|
||
if (j?.id) {
|
||
lastSeenAtByJobIdRef.current[j.id] = now
|
||
}
|
||
}
|
||
|
||
setJobs((prev) => {
|
||
const byId = new Map<string, RecordJob>()
|
||
|
||
// 1) Server gewinnt immer
|
||
for (const j of serverItems) {
|
||
if (!j?.id) continue
|
||
byId.set(j.id, j)
|
||
}
|
||
|
||
// 2) Lokale aktive Jobs kurz weiter behalten, wenn sie serverseitig
|
||
// vorübergehend fehlen
|
||
for (const j of prev) {
|
||
if (!j?.id) continue
|
||
if (byId.has(j.id)) continue
|
||
|
||
const status = String(j.status ?? '').trim().toLowerCase()
|
||
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
||
const isTerminal =
|
||
status === 'stopped' ||
|
||
status === 'finished' ||
|
||
status === 'failed' ||
|
||
status === 'done' ||
|
||
status === 'completed' ||
|
||
status === 'canceled' ||
|
||
status === 'cancelled'
|
||
|
||
const isActive =
|
||
!j.endedAt &&
|
||
!isTerminal &&
|
||
(status === 'running' || phase === 'recording' || phase === '')
|
||
|
||
const lastSeen = lastSeenAtByJobIdRef.current[j.id] ?? 0
|
||
const withinGrace = now - lastSeen < MISSING_JOB_GRACE_MS
|
||
|
||
if (isActive || withinGrace) {
|
||
byId.set(j.id, j)
|
||
}
|
||
}
|
||
|
||
const next = Array.from(byId.values()).sort((a, b) => {
|
||
const ta = new Date(a.startedAt || 0).getTime()
|
||
const tb = new Date(b.startedAt || 0).getTime()
|
||
return tb - ta
|
||
})
|
||
|
||
jobsRef.current = next
|
||
return next
|
||
})
|
||
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
void checkAuth()
|
||
}, [checkAuth])
|
||
|
||
useEffect(() => {
|
||
if (!authed) return
|
||
void loadPendingAutoStarts()
|
||
}, [authed, loadPendingAutoStarts])
|
||
|
||
useEffect(() => {
|
||
try {
|
||
window.localStorage.setItem(DONE_SORT_KEY, doneSort)
|
||
} catch {}
|
||
}, [doneSort])
|
||
|
||
useEffect(() => {
|
||
if (!authed) return
|
||
if (selectedTab !== 'finished') return
|
||
void loadDonePage(donePage, doneSort)
|
||
}, [authed, selectedTab, donePage, doneSort, loadDonePage])
|
||
|
||
const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
|
||
const h = String(m?.host ?? '').toLowerCase()
|
||
const input = String(m?.input ?? '').toLowerCase()
|
||
return h.includes('chaturbate') || input.includes('chaturbate.com')
|
||
}, [])
|
||
|
||
const applyJobDelta = useCallback((msg: JobEvent) => {
|
||
const jobId = String(msg?.jobId ?? '').trim()
|
||
if (!jobId) return
|
||
|
||
const now = Date.now()
|
||
|
||
const mergeJob = (prevJob: RecordJob | null | undefined): RecordJob => {
|
||
return {
|
||
...(prevJob ?? ({} as RecordJob)),
|
||
id: jobId,
|
||
model: msg.model ?? (prevJob as any)?.model,
|
||
|
||
status: (msg.status as any) ?? (prevJob?.status ?? 'running'),
|
||
sourceUrl: msg.sourceUrl ?? ((prevJob as any)?.sourceUrl ?? ''),
|
||
output: msg.output ?? (prevJob?.output ?? ''),
|
||
startedAt: msg.startedAt ?? (prevJob?.startedAt ?? ''),
|
||
endedAt: msg.endedAt ?? (prevJob?.endedAt ?? undefined),
|
||
|
||
phase: msg.phase ?? (prevJob as any)?.phase,
|
||
progress: msg.progress ?? (prevJob as any)?.progress,
|
||
startedAtMs: msg.startedAtMs ?? (prevJob as any)?.startedAtMs,
|
||
endedAtMs: msg.endedAtMs ?? (prevJob as any)?.endedAtMs,
|
||
sizeBytes: msg.sizeBytes ?? (prevJob as any)?.sizeBytes,
|
||
durationSeconds: msg.durationSeconds ?? (prevJob as any)?.durationSeconds,
|
||
previewState: msg.previewState ?? (prevJob as any)?.previewState,
|
||
roomStatus: msg.roomStatus ?? (prevJob as any)?.roomStatus,
|
||
isOnline: msg.isOnline ?? (prevJob as any)?.isOnline,
|
||
modelImageUrl: msg.modelImageUrl ?? (prevJob as any)?.modelImageUrl,
|
||
modelChatRoomUrl: msg.modelChatRoomUrl ?? (prevJob as any)?.modelChatRoomUrl,
|
||
|
||
postWorkKey: msg.postWorkKey ?? (prevJob as any)?.postWorkKey,
|
||
postWork: msg.postWork
|
||
? {
|
||
...((prevJob as any)?.postWork ?? {}),
|
||
...msg.postWork,
|
||
}
|
||
: (prevJob as any)?.postWork,
|
||
} as any
|
||
}
|
||
|
||
if (msg.type === 'job_remove') {
|
||
delete lastSeenAtByJobIdRef.current[jobId]
|
||
|
||
setJobs((prev) => {
|
||
const next = prev.filter((j) => String((j as any).id ?? '').trim() !== jobId)
|
||
jobsRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPlayerJob((prev) =>
|
||
prev && String((prev as any).id ?? '').trim() === jobId ? null : prev
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
if (msg.type !== 'job_upsert') return
|
||
|
||
lastSeenAtByJobIdRef.current[jobId] = now
|
||
|
||
setJobs((prev) => {
|
||
const idx = prev.findIndex(
|
||
(j) => String((j as any).id ?? '').trim() === jobId
|
||
)
|
||
|
||
const prevJob = idx >= 0 ? prev[idx] : null
|
||
const merged = mergeJob(prevJob)
|
||
|
||
// bestehender Job: nur ersetzen, Reihenfolge beibehalten
|
||
if (idx >= 0) {
|
||
const next = [...prev]
|
||
next[idx] = merged
|
||
jobsRef.current = next
|
||
return next
|
||
}
|
||
|
||
// neuer Job: vorne einfügen
|
||
const next = [merged, ...prev]
|
||
jobsRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPlayerJob((prev) => {
|
||
if (!prev) return prev
|
||
if (String((prev as any).id ?? '').trim() !== jobId) return prev
|
||
return mergeJob(prev)
|
||
})
|
||
}, [])
|
||
|
||
const flushQueuedJobEvents = useCallback(() => {
|
||
if (pendingJobEventsTimerRef.current != null) {
|
||
window.clearTimeout(pendingJobEventsTimerRef.current)
|
||
pendingJobEventsTimerRef.current = null
|
||
}
|
||
|
||
const batch = pendingJobEventsRef.current
|
||
if (batch.length === 0) return
|
||
|
||
pendingJobEventsRef.current = []
|
||
|
||
// pro Job nur das letzte Event behalten
|
||
const byJobId = new Map<string, JobEvent>()
|
||
const passthrough: JobEvent[] = []
|
||
|
||
for (const msg of batch) {
|
||
const type = String(msg?.type ?? '').trim()
|
||
const jobId = String(msg?.jobId ?? '').trim()
|
||
|
||
if ((type === 'job_upsert' || type === 'job_remove') && jobId) {
|
||
byJobId.set(jobId, msg)
|
||
continue
|
||
}
|
||
|
||
passthrough.push(msg)
|
||
}
|
||
|
||
for (const msg of byJobId.values()) {
|
||
applyJobDelta(msg)
|
||
}
|
||
|
||
for (const msg of passthrough) {
|
||
applyJobDelta(msg)
|
||
}
|
||
}, [applyJobDelta])
|
||
|
||
const queueJobDelta = useCallback((msg: JobEvent) => {
|
||
pendingJobEventsRef.current.push(msg)
|
||
|
||
if (pendingJobEventsTimerRef.current != null) return
|
||
|
||
pendingJobEventsTimerRef.current = window.setTimeout(() => {
|
||
flushQueuedJobEvents()
|
||
}, 120)
|
||
}, [flushQueuedJobEvents])
|
||
|
||
const ensureModelEventListener = useCallback((name: string) => {
|
||
const es = eventSourceRef.current
|
||
const handler = onModelJobEventRef.current
|
||
|
||
const n = String(name ?? '').trim().toLowerCase()
|
||
if (!es || !handler || !n) return
|
||
|
||
if (modelEventNamesRef.current.has(n)) return
|
||
|
||
modelEventNamesRef.current.add(n)
|
||
es.addEventListener(n, handler as any)
|
||
}, [])
|
||
|
||
const removeModelEventListener = useCallback((name: string) => {
|
||
const es = eventSourceRef.current
|
||
const handler = onModelJobEventRef.current
|
||
|
||
const n = String(name ?? '').trim().toLowerCase()
|
||
if (!es || !handler || !n) return
|
||
|
||
if (!modelEventNamesRef.current.has(n)) return
|
||
|
||
es.removeEventListener(n, handler as any)
|
||
modelEventNamesRef.current.delete(n)
|
||
}, [])
|
||
|
||
const isVisibleDownloadJobForSSE = (job: RecordJob): boolean => {
|
||
if (isPostworkJobForCount(job)) return false
|
||
if (isTerminalJobStatusForCount((job as any)?.status)) return false
|
||
if (Boolean((job as any)?.endedAt)) return false
|
||
return true
|
||
}
|
||
|
||
const isVisiblePostworkJobForSSE = (job: RecordJob): boolean => {
|
||
if (!isPostworkJobForCount(job)) return false
|
||
const pw = (job as any)?.postWork
|
||
const state = String(pw?.state ?? '').trim().toLowerCase()
|
||
if (String((job as any)?.postWorkKey ?? '').trim() && (!state || state === 'queued' || state === 'running')) {
|
||
return true
|
||
}
|
||
if (isTerminalJobStatusForCount((job as any)?.status)) return false
|
||
return true
|
||
}
|
||
|
||
const buildModelsByKey = useCallback((list: StoredModel[]) => {
|
||
const map: Record<string, StoredModel> = {}
|
||
|
||
const score = (x: StoredModel) =>
|
||
(x.favorite ? 4 : 0) +
|
||
(x.liked === true ? 2 : 0) +
|
||
(x.watching ? 1 : 0)
|
||
|
||
for (const m of Array.isArray(list) ? list : []) {
|
||
const k = String(m?.modelKey ?? '').trim().toLowerCase()
|
||
if (!k) continue
|
||
|
||
const cur = map[k]
|
||
if (!cur) {
|
||
map[k] = m
|
||
continue
|
||
}
|
||
|
||
const better = score(m) >= score(cur) ? m : cur
|
||
const other = better === m ? cur : m
|
||
|
||
map[k] = mergeStoredModel(other, better)
|
||
}
|
||
|
||
return map
|
||
}, [])
|
||
|
||
const applyRoomStateToModel = useCallback((
|
||
keyLower: string,
|
||
patch: {
|
||
roomStatus?: string
|
||
isOnline?: boolean
|
||
imageUrl?: string
|
||
chatRoomUrl?: string
|
||
}
|
||
) => {
|
||
if (!keyLower) return
|
||
|
||
setModelsByKey((prev) => {
|
||
const cur = prev[keyLower]
|
||
if (!cur) return prev
|
||
|
||
const next: StoredModel = {
|
||
...cur,
|
||
roomStatus:
|
||
patch.roomStatus != null
|
||
? String(patch.roomStatus).trim().toLowerCase()
|
||
: cur.roomStatus,
|
||
isOnline:
|
||
patch.isOnline != null
|
||
? Boolean(patch.isOnline)
|
||
: cur.isOnline,
|
||
imageUrl:
|
||
patch.imageUrl && String(patch.imageUrl).trim()
|
||
? String(patch.imageUrl).trim()
|
||
: cur.imageUrl,
|
||
chatRoomUrl:
|
||
patch.chatRoomUrl && String(patch.chatRoomUrl).trim()
|
||
? String(patch.chatRoomUrl).trim()
|
||
: cur.chatRoomUrl,
|
||
}
|
||
|
||
return { ...prev, [keyLower]: next }
|
||
})
|
||
|
||
setPendingWatchedRooms((prev) => {
|
||
const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
|
||
if (idx < 0) return prev
|
||
|
||
const copy = [...prev]
|
||
copy[idx] = {
|
||
...copy[idx],
|
||
currentShow: normalizePendingShow(patch.roomStatus),
|
||
imageUrl:
|
||
patch.imageUrl && String(patch.imageUrl).trim()
|
||
? String(patch.imageUrl).trim()
|
||
: copy[idx].imageUrl,
|
||
}
|
||
return copy
|
||
})
|
||
}, [])
|
||
|
||
|
||
const queuePendingAutoStart = useCallback((
|
||
keyLower: string,
|
||
url: string,
|
||
show?: string,
|
||
imageUrl?: string,
|
||
opts?: {
|
||
mode?: PendingAutoStartMode
|
||
nextProbeAtMs?: number
|
||
source?: PendingAutoStartSource
|
||
}
|
||
) => {
|
||
const key = String(keyLower ?? '').trim().toLowerCase()
|
||
const norm0 = normalizeHttpUrl(String(url ?? ''))
|
||
if (!key || !norm0) return
|
||
|
||
const norm = canonicalizeProviderUrl(norm0)
|
||
const mode: PendingAutoStartMode = opts?.mode ?? 'wait_public'
|
||
const source: PendingAutoStartSource = opts?.source === 'watched' ? 'watched' : 'manual'
|
||
|
||
const nextProbeAtMs =
|
||
mode === 'probe_retry'
|
||
? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000))
|
||
: undefined
|
||
|
||
const nextShow = normalizePendingShow(show)
|
||
const nextRetryAt =
|
||
mode === 'probe_retry' && Number.isFinite(nextProbeAtMs)
|
||
? Number(nextProbeAtMs)
|
||
: 0
|
||
|
||
// ✅ ALTE Werte VOR dem State-Update lesen
|
||
const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
|
||
const prevMode = (pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public') as PendingAutoStartMode
|
||
const prevSource = (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource
|
||
const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
|
||
|
||
const sameUrl = prevUrl === norm
|
||
const sameMode = prevMode === mode
|
||
const sameSource = prevSource === source
|
||
const sameRetryAt =
|
||
mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000
|
||
|
||
setPendingAutoStartByKey((prev) => {
|
||
const next = { ...(prev || {}), [key]: norm }
|
||
pendingAutoStartByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingAutoStartModeByKey((prev) => {
|
||
const next = { ...(prev || {}), [key]: mode }
|
||
pendingAutoStartModeByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingAutoStartSourceByKey((prev) => {
|
||
const next = { ...(prev || {}), [key]: source }
|
||
pendingAutoStartSourceByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingBlindRetryAtByKey((prev) => {
|
||
const next = { ...(prev || {}) }
|
||
|
||
if (mode === 'probe_retry') {
|
||
next[key] = nextRetryAt || (Date.now() + 60_000)
|
||
} else {
|
||
delete next[key]
|
||
}
|
||
|
||
pendingBlindRetryAtByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingWatchedRooms((prev) => {
|
||
const nextItem: PendingWatchedRoom = {
|
||
id: key,
|
||
modelKey: key,
|
||
url: norm,
|
||
currentShow: nextShow,
|
||
imageUrl: imageUrl || undefined,
|
||
}
|
||
|
||
const idx = prev.findIndex(
|
||
(x) => String(x.modelKey ?? '').trim().toLowerCase() === key
|
||
)
|
||
|
||
if (idx >= 0) {
|
||
const copy = [...prev]
|
||
copy[idx] = { ...copy[idx], ...nextItem }
|
||
return copy
|
||
}
|
||
|
||
return [nextItem, ...prev]
|
||
})
|
||
|
||
// ✅ Nur persistieren, wenn sich wirklich etwas geändert hat
|
||
if (sameUrl && sameMode && sameSource && sameRetryAt) {
|
||
return
|
||
}
|
||
|
||
void fetch('/api/pending-autostart', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
modelKey: key,
|
||
url: norm,
|
||
mode,
|
||
nextProbeAtMs: mode === 'probe_retry' ? nextRetryAt : undefined,
|
||
currentShow: nextShow,
|
||
imageUrl: imageUrl || undefined,
|
||
source,
|
||
}),
|
||
}).catch(() => {})
|
||
}, [])
|
||
|
||
const removePendingAutoStart = useCallback((keyLower: string) => {
|
||
const key = String(keyLower ?? '').trim().toLowerCase()
|
||
if (!key) return
|
||
|
||
const hadPending =
|
||
Boolean(pendingAutoStartByKeyRef.current[key]) ||
|
||
Boolean(pendingAutoStartModeByKeyRef.current[key]) ||
|
||
Boolean(pendingBlindRetryAtByKeyRef.current[key]) ||
|
||
Boolean(pendingAutoStartSourceByKeyRef.current[key])
|
||
|
||
if (!hadPending) return
|
||
|
||
setPendingAutoStartByKey((prev) => {
|
||
const next = { ...(prev || {}) }
|
||
delete next[key]
|
||
pendingAutoStartByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingAutoStartModeByKey((prev) => {
|
||
const next = { ...(prev || {}) }
|
||
delete next[key]
|
||
pendingAutoStartModeByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingBlindRetryAtByKey((prev) => {
|
||
const next = { ...(prev || {}) }
|
||
delete next[key]
|
||
pendingBlindRetryAtByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingAutoStartSourceByKey((prev) => {
|
||
const next = { ...(prev || {}) }
|
||
delete next[key]
|
||
pendingAutoStartSourceByKeyRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPendingWatchedRooms((prev) =>
|
||
prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== key)
|
||
)
|
||
|
||
void fetch(`/api/pending-autostart?modelKey=${encodeURIComponent(key)}`, {
|
||
method: 'DELETE',
|
||
}).catch(() => {})
|
||
}, [])
|
||
|
||
const selectedTabRef = useRef(selectedTab)
|
||
const donePageRef = useRef(donePage)
|
||
const doneSortRef = useRef(doneSort)
|
||
|
||
type AppModelsSnapshot = {
|
||
totalCount?: number
|
||
onlineModelsCount?: number
|
||
onlineWatchedModelsCount?: number
|
||
onlineFavCount?: number
|
||
onlineLikedCount?: number
|
||
models?: StoredModel[]
|
||
}
|
||
|
||
const [headerStats, setHeaderStats] = useState({
|
||
totalCount: 0,
|
||
onlineModelsCount: 0,
|
||
onlineWatchedModelsCount: 0,
|
||
onlineFavCount: 0,
|
||
onlineLikedCount: 0,
|
||
})
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (pendingJobEventsTimerRef.current != null) {
|
||
window.clearTimeout(pendingJobEventsTimerRef.current)
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
setModelsCount(headerStats.totalCount)
|
||
}, [headerStats.totalCount])
|
||
|
||
const handleModelsCountChange = useCallback((count: number) => {
|
||
const nextCount = Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
|
||
|
||
setHeaderStats((prev) =>
|
||
prev.totalCount === nextCount
|
||
? prev
|
||
: {
|
||
...prev,
|
||
totalCount: nextCount,
|
||
}
|
||
)
|
||
}, [])
|
||
|
||
const overviewInFlightRef = useRef(false)
|
||
const overviewLastSigRef = useRef('')
|
||
const overviewLastAtRef = useRef(0)
|
||
const overviewTimerRef = useRef<number | null>(null)
|
||
const overviewAbortRef = useRef<AbortController | null>(null)
|
||
const overviewPendingKeysRef = useRef<string[] | null>(null)
|
||
const overviewStatsInFlightRef = useRef(false)
|
||
const overviewStatsLastAtRef = useRef(0)
|
||
const overviewStatsAbortRef = useRef<AbortController | null>(null)
|
||
|
||
const applyHeaderStatsFromSnapshot = useCallback((data: AppModelsSnapshot | null | undefined) => {
|
||
if (!data) return
|
||
|
||
setHeaderStats((prev) => {
|
||
const totalCount = Number(data.totalCount)
|
||
const onlineModelsCount = Number(data.onlineModelsCount)
|
||
const onlineWatchedModelsCount = Number(data.onlineWatchedModelsCount)
|
||
const onlineFavCount = Number(data.onlineFavCount)
|
||
const onlineLikedCount = Number(data.onlineLikedCount)
|
||
|
||
const next = {
|
||
totalCount: Number.isFinite(totalCount) ? totalCount : prev.totalCount,
|
||
onlineModelsCount: Number.isFinite(onlineModelsCount) ? onlineModelsCount : prev.onlineModelsCount,
|
||
onlineWatchedModelsCount: Number.isFinite(onlineWatchedModelsCount)
|
||
? onlineWatchedModelsCount
|
||
: prev.onlineWatchedModelsCount,
|
||
onlineFavCount: Number.isFinite(onlineFavCount) ? onlineFavCount : prev.onlineFavCount,
|
||
onlineLikedCount: Number.isFinite(onlineLikedCount) ? onlineLikedCount : prev.onlineLikedCount,
|
||
}
|
||
|
||
return (
|
||
prev.totalCount === next.totalCount &&
|
||
prev.onlineModelsCount === next.onlineModelsCount &&
|
||
prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount &&
|
||
prev.onlineFavCount === next.onlineFavCount &&
|
||
prev.onlineLikedCount === next.onlineLikedCount
|
||
)
|
||
? prev
|
||
: next
|
||
})
|
||
}, [])
|
||
|
||
const mergeModelsSnapshot = useCallback((models: StoredModel[] | undefined | null) => {
|
||
const safeList = Array.isArray(models) ? models : []
|
||
if (safeList.length === 0) return
|
||
|
||
const built = buildModelsByKey(safeList)
|
||
|
||
setModelsByKey((prev) => {
|
||
const next = { ...prev }
|
||
let changed = false
|
||
|
||
for (const [k, incoming] of Object.entries(built)) {
|
||
const merged = mergeStoredModel(prev[k], incoming)
|
||
if (!storedModelShallowEqual(prev[k], merged)) {
|
||
next[k] = merged
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
return changed ? next : prev
|
||
})
|
||
}, [buildModelsByKey])
|
||
|
||
const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
|
||
const uniqKeys = Array.from(
|
||
new Set(
|
||
keys
|
||
.map((x) => String(x ?? '').trim().toLowerCase())
|
||
.filter(Boolean)
|
||
)
|
||
)
|
||
|
||
const sig = uniqKeys.join('|')
|
||
const now = Date.now()
|
||
|
||
if (overviewInFlightRef.current) {
|
||
overviewPendingKeysRef.current = uniqKeys
|
||
return
|
||
}
|
||
|
||
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
|
||
|
||
overviewInFlightRef.current = true
|
||
|
||
const controller = new AbortController()
|
||
overviewAbortRef.current = controller
|
||
|
||
try {
|
||
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store' as any,
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
keys: uniqKeys,
|
||
includeWatched: false,
|
||
includeStats: false,
|
||
includeModels: true,
|
||
}),
|
||
})
|
||
|
||
overviewLastSigRef.current = sig
|
||
overviewLastAtRef.current = now
|
||
|
||
mergeModelsSnapshot(data?.models)
|
||
} catch (error: any) {
|
||
if (error?.name === 'AbortError') return
|
||
// ignore
|
||
} finally {
|
||
overviewInFlightRef.current = false
|
||
if (overviewAbortRef.current === controller) {
|
||
overviewAbortRef.current = null
|
||
}
|
||
|
||
const pendingKeys = overviewPendingKeysRef.current
|
||
overviewPendingKeysRef.current = null
|
||
|
||
if (pendingKeys && pendingKeys.join('|') !== sig) {
|
||
if (overviewTimerRef.current != null) {
|
||
window.clearTimeout(overviewTimerRef.current)
|
||
}
|
||
|
||
overviewTimerRef.current = window.setTimeout(() => {
|
||
overviewTimerRef.current = null
|
||
void loadAppModelsSnapshot(pendingKeys)
|
||
}, MODELS_OVERVIEW_DEBOUNCE_MS)
|
||
}
|
||
}
|
||
}, [mergeModelsSnapshot])
|
||
|
||
const loadAppModelsStatsAndWatched = useCallback(async (force = false) => {
|
||
const now = Date.now()
|
||
|
||
if (overviewStatsInFlightRef.current) return
|
||
if (!force && now - overviewStatsLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
|
||
|
||
overviewStatsInFlightRef.current = true
|
||
|
||
const controller = new AbortController()
|
||
overviewStatsAbortRef.current = controller
|
||
|
||
try {
|
||
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
cache: 'no-store' as any,
|
||
signal: controller.signal,
|
||
body: JSON.stringify({
|
||
keys: [],
|
||
includeWatched: true,
|
||
includeStats: true,
|
||
includeModels: true,
|
||
}),
|
||
})
|
||
|
||
overviewStatsLastAtRef.current = Date.now()
|
||
applyHeaderStatsFromSnapshot(data)
|
||
mergeModelsSnapshot(data?.models)
|
||
} catch (error: any) {
|
||
if (error?.name === 'AbortError') return
|
||
// ignore
|
||
} finally {
|
||
overviewStatsInFlightRef.current = false
|
||
if (overviewStatsAbortRef.current === controller) {
|
||
overviewStatsAbortRef.current = null
|
||
}
|
||
}
|
||
}, [applyHeaderStatsFromSnapshot, mergeModelsSnapshot])
|
||
|
||
const maybeNotifyWatchedModelStatusChange = useCallback((
|
||
keyLower: string,
|
||
nextStatusRaw: unknown,
|
||
patch?: {
|
||
imageUrl?: string
|
||
}
|
||
) => {
|
||
if (!keyLower) return
|
||
if (!recSettingsRef.current.enableNotifications) return
|
||
if (!roomStatusBaselineReadyRef.current) return
|
||
|
||
const model = modelsByKeyRef.current[keyLower]
|
||
if (!model?.watching) {
|
||
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
||
|
||
// unknown nicht als "letzten echten Status" speichern
|
||
if (nextStatus !== 'unknown') {
|
||
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
|
||
}
|
||
return
|
||
}
|
||
|
||
const nextStatus = normalizeRoomStatusText(nextStatusRaw)
|
||
const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower])
|
||
|
||
// unknown niemals melden und auch nicht als letzten echten Status überschreiben
|
||
if (nextStatus === 'unknown') return
|
||
|
||
// keine Änderung
|
||
if (prevStatus === nextStatus) return
|
||
|
||
// keine "erste Sichtung" als Wechsel melden
|
||
const hadPreviousRealStatus =
|
||
Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) &&
|
||
prevStatus !== 'unknown'
|
||
|
||
lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus
|
||
|
||
if (!hadPreviousRealStatus) return
|
||
|
||
const displayName =
|
||
String(model?.modelKey ?? keyLower).trim() || keyLower
|
||
|
||
const imageUrl =
|
||
String(patch?.imageUrl ?? '').trim() ||
|
||
String(model?.imageUrl ?? '').trim() ||
|
||
undefined
|
||
|
||
const title =
|
||
nextStatus === 'public'
|
||
? `${displayName} ist live`
|
||
: `${displayName} Status geändert`
|
||
|
||
const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}`
|
||
|
||
notifyRef.current?.info(title, message, {
|
||
imageUrl,
|
||
imageAlt: displayName,
|
||
durationMs: 3000,
|
||
onClick: () => {
|
||
window.dispatchEvent(
|
||
new CustomEvent('open-model-details', {
|
||
detail: { modelKey: keyLower },
|
||
})
|
||
)
|
||
},
|
||
})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
onModelJobEventRef.current = (ev: MessageEvent) => {
|
||
try {
|
||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||
const modelKey = String(msg?.model ?? '').trim().toLowerCase()
|
||
|
||
if (msg?.type === 'job_upsert') {
|
||
if (modelKey) {
|
||
const statusLower = String(msg?.status ?? '').trim().toLowerCase()
|
||
const phaseLower = String(msg?.phase ?? '').trim().toLowerCase()
|
||
|
||
const isActiveVisibleRecording =
|
||
statusLower === 'running' &&
|
||
(phaseLower === '' || phaseLower === 'recording')
|
||
|
||
if (isActiveVisibleRecording) {
|
||
removePendingAutoStart(modelKey)
|
||
}
|
||
|
||
const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase()
|
||
|
||
if (roomStatus) {
|
||
const isOnline =
|
||
typeof msg?.isOnline === 'boolean'
|
||
? Boolean(msg.isOnline)
|
||
: roomStatus !== 'offline'
|
||
|
||
maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, {
|
||
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
|
||
})
|
||
|
||
applyRoomStateToModel(modelKey, {
|
||
roomStatus,
|
||
isOnline,
|
||
imageUrl: String(msg?.modelImageUrl ?? '').trim(),
|
||
chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
queueJobDelta(msg)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
}, [applyRoomStateToModel, maybeNotifyWatchedModelStatusChange, queueJobDelta, removePendingAutoStart])
|
||
|
||
useEffect(() => {
|
||
const onOpen = (ev: Event) => {
|
||
const e = ev as CustomEvent<{ modelKey?: string }>
|
||
const raw0 = (e.detail?.modelKey ?? '').trim()
|
||
if (!raw0) return
|
||
|
||
// 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen
|
||
// Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key
|
||
const looksLikeKey =
|
||
!raw0.includes(' ') &&
|
||
!raw0.includes('/') &&
|
||
!raw0.includes('\\')
|
||
|
||
if (looksLikeKey) {
|
||
const k = raw0.replace(/^@/, '').trim().toLowerCase()
|
||
if (k) setDetailsModelKey(k)
|
||
return
|
||
}
|
||
|
||
// 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren
|
||
const norm0 = normalizeHttpUrl(raw0)
|
||
if (!norm0) {
|
||
// Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist)
|
||
let k = raw0.replace(/^https?:\/\//i, '')
|
||
if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k
|
||
if (k.includes(':')) k = k.split(':').pop() || k
|
||
k = k.trim().toLowerCase()
|
||
if (k) setDetailsModelKey(k)
|
||
return
|
||
}
|
||
|
||
const norm = canonicalizeProviderUrl(norm0)
|
||
const keyLower = providerKeyLowerFromUrl(norm)
|
||
|
||
if (keyLower) setDetailsModelKey(keyLower)
|
||
}
|
||
|
||
window.addEventListener('open-model-details', onOpen as any)
|
||
return () => window.removeEventListener('open-model-details', onOpen as any)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const onOpen = (ev: Event) => {
|
||
const e = ev as CustomEvent<{ jobId?: string; output?: string }>
|
||
const jobId = String(e.detail?.jobId ?? '').trim()
|
||
const output = String(e.detail?.output ?? '').trim()
|
||
|
||
let hit: RecordJob | null = null
|
||
|
||
if (jobId) {
|
||
hit =
|
||
jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
|
||
doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
|
||
null
|
||
}
|
||
|
||
if (!hit && output) {
|
||
const wanted = baseName(output)
|
||
hit =
|
||
jobs.find((j) => baseName(j.output || '') === wanted) ??
|
||
doneJobs.find((j) => baseName(j.output || '') === wanted) ??
|
||
null
|
||
}
|
||
|
||
if (hit) {
|
||
setSplitJob(hit)
|
||
setSplitModalKey((k) => k + 1)
|
||
setSplitModalOpen(true)
|
||
}
|
||
}
|
||
|
||
window.addEventListener('open-video-splitter', onOpen as EventListener)
|
||
return () => window.removeEventListener('open-video-splitter', onOpen as EventListener)
|
||
}, [jobs, doneJobs])
|
||
|
||
const upsertModelCache = useCallback((m: StoredModel) => {
|
||
const now = Date.now()
|
||
const cur = modelsCacheRef.current
|
||
if (!cur) {
|
||
modelsCacheRef.current = { ts: now, list: [m] }
|
||
return
|
||
}
|
||
|
||
cur.ts = now
|
||
const idx = cur.list.findIndex((x) => x.id === m.id)
|
||
|
||
if (idx >= 0) {
|
||
cur.list[idx] = mergeStoredModel(cur.list[idx], m)
|
||
} else {
|
||
cur.list.unshift(m)
|
||
}
|
||
}, [])
|
||
|
||
const recSettingsRef = useRef(recSettings)
|
||
useEffect(() => {
|
||
recSettingsRef.current = recSettings
|
||
}, [recSettings])
|
||
|
||
const [autostartState, setAutostartState] = useState<AutostartState>({
|
||
paused: false,
|
||
pausedByUser: false,
|
||
pausedByDisk: false,
|
||
})
|
||
|
||
const applyAutostartState = useCallback((data: unknown) => {
|
||
const d = (data ?? {}) as any
|
||
|
||
setAutostartState({
|
||
paused: Boolean(d?.paused),
|
||
pausedByUser: Boolean(d?.pausedByUser),
|
||
pausedByDisk: Boolean(d?.pausedByDisk),
|
||
})
|
||
}, [])
|
||
|
||
const loadAutostartState = useCallback(async () => {
|
||
const s = await apiJSON<AutostartState>('/api/autostart/state', {
|
||
cache: 'no-store' as any,
|
||
})
|
||
|
||
applyAutostartState(s)
|
||
}, [applyAutostartState])
|
||
|
||
useEffect(() => {
|
||
busyRef.current = busy
|
||
}, [busy])
|
||
useEffect(() => {
|
||
cookiesRef.current = cookies
|
||
}, [cookies])
|
||
useEffect(() => {
|
||
jobsRef.current = jobs
|
||
}, [jobs])
|
||
|
||
// pending start falls gerade busy
|
||
const lastClipboardUrlRef = useRef<string>('')
|
||
|
||
const chaturbateStoreKeysLower = useMemo(() => {
|
||
const set = new Set<string>()
|
||
for (const m of Object.values(modelsByKey)) {
|
||
if (!isChaturbateStoreModel(m)) continue
|
||
const k = lower(String(m?.modelKey ?? ''))
|
||
if (k) set.add(k)
|
||
}
|
||
return Array.from(set)
|
||
}, [modelsByKey, isChaturbateStoreModel])
|
||
|
||
const watchedModelKeysLower = useMemo(() => {
|
||
const set = new Set<string>()
|
||
|
||
for (const m of Object.values(modelsByKey)) {
|
||
if (!m?.watching) continue
|
||
|
||
const k = lower(String(m?.modelKey ?? ''))
|
||
if (!k) continue
|
||
|
||
set.add(k)
|
||
}
|
||
|
||
return Array.from(set)
|
||
}, [modelsByKey])
|
||
|
||
function candidateModelKeysFromJob(job: RecordJob): string[] {
|
||
const anyJob = job as any
|
||
|
||
const rawSource =
|
||
String(anyJob?.sourceUrl ?? anyJob?.SourceURL ?? '').trim()
|
||
|
||
const norm0 = normalizeHttpUrl(rawSource)
|
||
const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
|
||
const keyFromUrl = norm ? providerKeyLowerFromUrl(norm) : ''
|
||
|
||
const candidates = [
|
||
anyJob?.model,
|
||
anyJob?.modelKey,
|
||
anyJob?.meta?.modelKey,
|
||
anyJob?.meta?.model?.key,
|
||
anyJob?.meta?.model?.name,
|
||
keyFromUrl,
|
||
modelKeyFromFilename(anyJob?.output || ''),
|
||
]
|
||
|
||
const seen = new Set<string>()
|
||
const out: string[] = []
|
||
|
||
for (const value of candidates) {
|
||
const k = String(value ?? '').trim().toLowerCase()
|
||
if (!k || seen.has(k)) continue
|
||
seen.add(k)
|
||
out.push(k)
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
const requiredModelKeys = useMemo(() => {
|
||
const set = new Set<string>()
|
||
|
||
for (const j of jobs) {
|
||
for (const k of candidateModelKeysFromJob(j)) {
|
||
set.add(k)
|
||
}
|
||
}
|
||
|
||
for (const j of doneJobs) {
|
||
for (const k of candidateModelKeysFromJob(j)) {
|
||
set.add(k)
|
||
}
|
||
}
|
||
|
||
for (const p of pendingWatchedRooms) {
|
||
const k = String(p?.modelKey ?? '').trim().toLowerCase()
|
||
if (k) set.add(k)
|
||
}
|
||
|
||
if (playerJob) {
|
||
for (const k of candidateModelKeysFromJob(playerJob)) {
|
||
set.add(k)
|
||
}
|
||
}
|
||
|
||
if (detailsModelKey) {
|
||
const k = String(detailsModelKey ?? '').trim().toLowerCase()
|
||
if (k) set.add(k)
|
||
}
|
||
|
||
return Array.from(set).sort()
|
||
}, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey])
|
||
|
||
const requiredModelKeysSig = useMemo(
|
||
() => requiredModelKeys.join('|'),
|
||
[requiredModelKeys]
|
||
)
|
||
|
||
const requiredModelKeysRef = useRef<string[]>(requiredModelKeys)
|
||
|
||
useEffect(() => {
|
||
requiredModelKeysRef.current = requiredModelKeys
|
||
}, [requiredModelKeys])
|
||
|
||
const lastOverviewSigRef = useRef<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (!authed) return
|
||
|
||
const scheduleOverviewLoad = () => {
|
||
if (overviewTimerRef.current != null) {
|
||
window.clearTimeout(overviewTimerRef.current)
|
||
}
|
||
|
||
overviewTimerRef.current = window.setTimeout(() => {
|
||
void loadAppModelsSnapshot(requiredModelKeys)
|
||
}, MODELS_OVERVIEW_DEBOUNCE_MS)
|
||
}
|
||
|
||
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
|
||
lastOverviewSigRef.current = requiredModelKeysSig
|
||
scheduleOverviewLoad()
|
||
void loadAppModelsStatsAndWatched(false)
|
||
}
|
||
|
||
const onChanged = (ev: Event) => {
|
||
const e = ev as CustomEvent<any>
|
||
const detail = e?.detail ?? {}
|
||
const updated = detail?.model
|
||
|
||
if (updated && typeof updated === 'object') {
|
||
const k = String(updated.modelKey ?? '').toLowerCase().trim()
|
||
|
||
const mergedUpdated = k
|
||
? mergeStoredModel(modelsByKeyRef.current[k], updated)
|
||
: mergeStoredModel(undefined, updated)
|
||
|
||
if (k) {
|
||
setModelsByKey((prev) => ({
|
||
...prev,
|
||
[k]: mergeStoredModel(prev[k], updated),
|
||
}))
|
||
}
|
||
|
||
try {
|
||
upsertModelCache(mergedUpdated)
|
||
} catch {}
|
||
|
||
setPlayerModel((prev) =>
|
||
prev?.id === updated.id ? mergeStoredModel(prev, updated) : prev
|
||
)
|
||
|
||
scheduleOverviewLoad()
|
||
void loadAppModelsStatsAndWatched(true)
|
||
return
|
||
}
|
||
|
||
if (detail?.removed) {
|
||
const removedId = String(detail?.id ?? '').trim()
|
||
const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim()
|
||
|
||
if (removedKey) {
|
||
setModelsByKey((prev) => {
|
||
const { [removedKey]: _drop, ...rest } = prev
|
||
return rest
|
||
})
|
||
}
|
||
|
||
if (removedId) {
|
||
setPlayerModel((prev) => (prev?.id === removedId ? null : prev))
|
||
}
|
||
|
||
scheduleOverviewLoad()
|
||
void loadAppModelsStatsAndWatched(true)
|
||
return
|
||
}
|
||
|
||
scheduleOverviewLoad()
|
||
void loadAppModelsStatsAndWatched(false)
|
||
}
|
||
|
||
window.addEventListener('models-changed', onChanged as any)
|
||
|
||
return () => {
|
||
window.removeEventListener('models-changed', onChanged as any)
|
||
|
||
if (overviewTimerRef.current != null) {
|
||
window.clearTimeout(overviewTimerRef.current)
|
||
overviewTimerRef.current = null
|
||
}
|
||
}
|
||
}, [authed, loadAppModelsSnapshot, loadAppModelsStatsAndWatched, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
||
|
||
useEffect(() => {
|
||
if (authed) return
|
||
|
||
if (overviewTimerRef.current != null) {
|
||
window.clearTimeout(overviewTimerRef.current)
|
||
overviewTimerRef.current = null
|
||
}
|
||
|
||
overviewPendingKeysRef.current = null
|
||
overviewAbortRef.current?.abort()
|
||
overviewAbortRef.current = null
|
||
overviewInFlightRef.current = false
|
||
overviewStatsAbortRef.current?.abort()
|
||
overviewStatsAbortRef.current = null
|
||
overviewStatsInFlightRef.current = false
|
||
overviewStatsLastAtRef.current = 0
|
||
}, [authed])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (overviewTimerRef.current != null) {
|
||
window.clearTimeout(overviewTimerRef.current)
|
||
overviewTimerRef.current = null
|
||
}
|
||
|
||
overviewPendingKeysRef.current = null
|
||
overviewAbortRef.current?.abort()
|
||
overviewAbortRef.current = null
|
||
overviewInFlightRef.current = false
|
||
overviewStatsAbortRef.current?.abort()
|
||
overviewStatsAbortRef.current = null
|
||
overviewStatsInFlightRef.current = false
|
||
overviewStatsLastAtRef.current = 0
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
donePageRef.current = donePage
|
||
}, [donePage])
|
||
|
||
useEffect(() => {
|
||
doneSortRef.current = doneSort
|
||
}, [doneSort])
|
||
|
||
// ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
|
||
const modelsByKeyRef = useRef(modelsByKey)
|
||
useEffect(() => {
|
||
modelsByKeyRef.current = modelsByKey
|
||
}, [modelsByKey])
|
||
|
||
useEffect(() => {
|
||
const next: Record<string, string> = {}
|
||
|
||
for (const [rawKey, model] of Object.entries(modelsByKey || {})) {
|
||
const key = String(rawKey ?? '').trim().toLowerCase()
|
||
if (!key) continue
|
||
|
||
const status = normalizeRoomStatusText((model as any)?.roomStatus)
|
||
next[key] = status
|
||
}
|
||
|
||
lastKnownRoomStatusByKeyRef.current = next
|
||
|
||
if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) {
|
||
roomStatusBaselineReadyRef.current = true
|
||
}
|
||
}, [modelsByKey])
|
||
|
||
const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
|
||
useEffect(() => {
|
||
pendingAutoStartByKeyRef.current = pendingAutoStartByKey
|
||
}, [pendingAutoStartByKey])
|
||
|
||
useEffect(() => {
|
||
const keys = Object.keys(pendingAutoStartByKey || {})
|
||
if (keys.length === 0) {
|
||
setPendingWatchedRooms([])
|
||
return
|
||
}
|
||
|
||
setPendingWatchedRooms((prev) => {
|
||
const prevByKey = new Map(
|
||
prev.map((x) => [String(x.modelKey ?? '').trim().toLowerCase(), x] as const)
|
||
)
|
||
|
||
const next: PendingWatchedRoom[] = keys.map((rawKey) => {
|
||
const key = String(rawKey ?? '').trim().toLowerCase()
|
||
const url = String((pendingAutoStartByKey as any)?.[key] ?? '').trim()
|
||
const model = (modelsByKey as any)?.[key] ?? null
|
||
const prevItem = prevByKey.get(key)
|
||
|
||
return {
|
||
id: key,
|
||
modelKey: key,
|
||
url,
|
||
currentShow: normalizePendingShow(model?.roomStatus ?? prevItem?.currentShow),
|
||
imageUrl:
|
||
String(model?.imageUrl ?? '').trim() ||
|
||
prevItem?.imageUrl ||
|
||
undefined,
|
||
}
|
||
})
|
||
|
||
return next
|
||
})
|
||
}, [pendingAutoStartByKey, modelsByKey])
|
||
|
||
useEffect(() => {
|
||
if (!authed) return
|
||
|
||
const id = window.setInterval(() => {
|
||
void loadPendingAutoStarts()
|
||
}, document.hidden ? 8000 : 3000)
|
||
|
||
return () => window.clearInterval(id)
|
||
}, [authed, loadPendingAutoStarts])
|
||
|
||
const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)
|
||
|
||
useEffect(() => {
|
||
chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
|
||
}, [chaturbateStoreKeysLower])
|
||
|
||
useEffect(() => {
|
||
selectedTabRef.current = selectedTab
|
||
}, [selectedTab])
|
||
|
||
useEffect(() => {
|
||
if (!recSettings.useChaturbateApi) {
|
||
setRoomStatusByModelKey({})
|
||
return
|
||
}
|
||
|
||
const next: Record<string, string> = {}
|
||
|
||
for (const j of jobs) {
|
||
const key = chaturbateModelKeyFromJob(j)
|
||
if (!key) continue
|
||
|
||
const direct = String((j as any)?.roomStatus ?? '').trim().toLowerCase()
|
||
if (direct) {
|
||
next[key] = direct
|
||
continue
|
||
}
|
||
|
||
const fromModel = String((modelsByKey[key] as any)?.roomStatus ?? '').trim().toLowerCase()
|
||
if (fromModel) {
|
||
next[key] = fromModel
|
||
}
|
||
}
|
||
|
||
setRoomStatusByModelKey(next)
|
||
}, [jobs, modelsByKey, recSettings.useChaturbateApi])
|
||
|
||
useEffect(() => {
|
||
if (!authed || !recSettings.useChaturbateApi) {
|
||
setCbOnlineFetchedAt('')
|
||
|
||
setHeaderStats((prev) => ({
|
||
...prev,
|
||
onlineModelsCount: 0,
|
||
onlineWatchedModelsCount: 0,
|
||
onlineFavCount: 0,
|
||
onlineLikedCount: 0,
|
||
}))
|
||
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
let timer: number | null = null
|
||
let lastSeenFetchedAt = ''
|
||
|
||
const tick = async (refresh = false) => {
|
||
try {
|
||
const onlineRes = await fetch(
|
||
refresh ? '/api/chaturbate/online?refresh=1' : '/api/chaturbate/online',
|
||
{ method: 'GET', cache: 'no-store' }
|
||
)
|
||
|
||
if (!onlineRes.ok) return
|
||
|
||
const data = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
|
||
if (cancelled || !data) return
|
||
|
||
const fetchedAt = String(data.fetchedAt ?? '').trim()
|
||
const safeFetchedAt =
|
||
fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z' ? fetchedAt : ''
|
||
|
||
const totalRaw = Number(data.total ?? 0)
|
||
const total = Number.isFinite(totalRaw) && totalRaw >= 0 ? totalRaw : 0
|
||
|
||
// Online-Gesamtcounter sofort aus der Online-API aktualisieren
|
||
setHeaderStats((prev) => {
|
||
if (prev.onlineModelsCount === total) return prev
|
||
return {
|
||
...prev,
|
||
onlineModelsCount: total,
|
||
}
|
||
})
|
||
|
||
setCbOnlineFetchedAt(safeFetchedAt)
|
||
|
||
// Derselbe erfolgreiche Snapshot triggert den Refresh
|
||
// der watched/fav/like Counter + modelsByKey
|
||
if (safeFetchedAt && safeFetchedAt !== lastSeenFetchedAt) {
|
||
lastSeenFetchedAt = safeFetchedAt
|
||
void loadAppModelsStatsAndWatched(true)
|
||
void loadAppModelsSnapshot(requiredModelKeysRef.current)
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
void tick(true)
|
||
|
||
timer = window.setInterval(() => {
|
||
void tick(false)
|
||
}, 10_000)
|
||
|
||
return () => {
|
||
cancelled = true
|
||
if (timer != null) window.clearInterval(timer)
|
||
}
|
||
}, [authed, recSettings.useChaturbateApi, loadAppModelsSnapshot, loadAppModelsStatsAndWatched])
|
||
|
||
// ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
|
||
const startUrl = useCallback(async (
|
||
rawUrl: string,
|
||
opts?: { silent?: boolean; immediate?: boolean }
|
||
): Promise<boolean> => {
|
||
const norm0 = normalizeHttpUrl(rawUrl)
|
||
if (!norm0) return false
|
||
|
||
const norm = canonicalizeProviderUrl(norm0)
|
||
const silent = Boolean(opts?.silent)
|
||
const immediate = Boolean(opts?.immediate)
|
||
|
||
if (!silent) setError(null)
|
||
|
||
const provider = getProviderFromNormalizedUrl(norm)
|
||
if (!provider) {
|
||
if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.')
|
||
return false
|
||
}
|
||
|
||
const currentCookies = cookiesRef.current
|
||
|
||
if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
|
||
if (!silent) {
|
||
setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Duplicate-running guard (normalisiert vergleichen)
|
||
const alreadyRunning = jobsRef.current.some((j) => {
|
||
if (String(j.status || '').toLowerCase() !== 'running') return false
|
||
|
||
// Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
|
||
if ((j as any).endedAt) return false
|
||
|
||
const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
|
||
const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
|
||
return jNorm === norm
|
||
})
|
||
if (alreadyRunning) return true
|
||
|
||
// Backend-only Autostart:
|
||
// Frontend speichert nur pending, Backend startet eigenständig.
|
||
|
||
const keyLower = providerKeyLowerFromUrl(norm)
|
||
|
||
// Nur automatische Starts in pending-autostart schieben.
|
||
// Manuelle UI-Starts müssen sofort direkt starten.
|
||
if (!immediate && !recSettingsRef.current.autoStartAddedDownloads) {
|
||
return true
|
||
}
|
||
|
||
if (!immediate) {
|
||
if (
|
||
provider === 'chaturbate' &&
|
||
recSettingsRef.current.useProviderApis &&
|
||
recSettingsRef.current.useChaturbateApi &&
|
||
keyLower
|
||
) {
|
||
queuePendingAutoStart(keyLower, norm, 'unknown', undefined, {
|
||
mode: 'probe_retry',
|
||
source: 'manual',
|
||
})
|
||
return true
|
||
}
|
||
|
||
if (
|
||
provider === 'mfc' &&
|
||
recSettingsRef.current.useProviderApis &&
|
||
recSettingsRef.current.useMyFreeCamsApi &&
|
||
keyLower
|
||
) {
|
||
queuePendingAutoStart(keyLower, norm, 'offline', undefined, {
|
||
mode: 'probe_retry',
|
||
source: 'manual',
|
||
})
|
||
return true
|
||
}
|
||
}
|
||
|
||
try {
|
||
const cookieString = Object.entries(currentCookies)
|
||
.map(([k, v]) => `${k}=${v}`)
|
||
.join('; ')
|
||
|
||
const created = await apiJSON<RecordJob>('/api/record', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
url: norm,
|
||
cookie: cookieString,
|
||
}),
|
||
})
|
||
|
||
// Wichtig: model lokal sofort mitschleppen, damit overview / modelsByKey
|
||
// nicht erst nach SSE oder Dateinamen-Erkennung korrekt werden.
|
||
const localModelKey = providerKeyLowerFromUrl(norm)
|
||
const createdWithModel = localModelKey
|
||
? ({ ...created, model: localModelKey } as RecordJob)
|
||
: created
|
||
|
||
// verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
|
||
// und kurz danach kommt der Job nochmal über SSE/polling rein.
|
||
if ((createdWithModel as any)?.id) {
|
||
startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true
|
||
}
|
||
|
||
setJobs((prev) => {
|
||
const next = upsertJobById(prev, createdWithModel)
|
||
jobsRef.current = next
|
||
return next
|
||
})
|
||
|
||
return true
|
||
} catch (e: any) {
|
||
const msg = e?.message ?? String(e)
|
||
|
||
// Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
|
||
if (isCookieGateError(msg)) {
|
||
showMissingCookiesMessage({ silent })
|
||
return false
|
||
}
|
||
|
||
if (!silent) setError(msg)
|
||
return false
|
||
}
|
||
}, [])
|
||
|
||
// ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
const load = async () => {
|
||
try {
|
||
const s = await apiJSON<RecorderSettingsState>('/api/settings', { cache: 'no-store' })
|
||
if (!cancelled && s) setRecSettings({ ...DEFAULT_RECORDER_SETTINGS, ...s })
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const onUpdated = () => void load()
|
||
|
||
let wasHidden = document.hidden
|
||
const onVisible = () => {
|
||
const becameVisible = wasHidden && !document.hidden
|
||
wasHidden = document.hidden
|
||
if (becameVisible) void load()
|
||
}
|
||
|
||
window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
|
||
document.addEventListener('visibilitychange', onVisible)
|
||
|
||
void load()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
|
||
document.removeEventListener('visibilitychange', onVisible)
|
||
}
|
||
}, [])
|
||
|
||
const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies])
|
||
|
||
const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => {
|
||
modelsCacheRef.current = null
|
||
setPlayerModel(null)
|
||
setPlayerJob(job)
|
||
setPlayerExpanded(false)
|
||
setPlayerStartAtSec(
|
||
typeof startAtSec === 'number' && Number.isFinite(startAtSec) && startAtSec >= 0
|
||
? startAtSec
|
||
: null
|
||
)
|
||
}, [])
|
||
|
||
const openSplitModal = useCallback((job: RecordJob) => {
|
||
setSplitJob(job)
|
||
setSplitModalKey((k) => k + 1)
|
||
setSplitModalOpen(true)
|
||
}, [])
|
||
|
||
// ✅ Anzahl Watched Models (aus Store), die online sind
|
||
const onlineModelsCount = headerStats.onlineModelsCount
|
||
const onlineWatchedModelsCount = headerStats.onlineWatchedModelsCount
|
||
const onlineFavCount = headerStats.onlineFavCount
|
||
const onlineLikedCount = headerStats.onlineLikedCount
|
||
|
||
const runningTabCount = useMemo(() => {
|
||
return jobs.filter((j) => {
|
||
const status = String((j as any)?.status ?? '').trim().toLowerCase()
|
||
const phase = String((j as any)?.phase ?? '').trim().toLowerCase()
|
||
|
||
// Nacharbeit / Postwork nicht mitzählen
|
||
if (isPostworkJobForCount(j)) return false
|
||
|
||
// Fertige/abgebrochene/fehlgeschlagene Jobs nicht mitzählen
|
||
if (isTerminalJobStatusForCount(status)) return false
|
||
|
||
// Sobald endedAt gesetzt ist, ist die aktive Aufnahme vorbei
|
||
if (Boolean((j as any)?.endedAt)) return false
|
||
|
||
// Nur wirklich laufende Downloads zählen
|
||
return status === 'running' && (phase === '' || phase === 'recording')
|
||
}).length
|
||
}, [jobs])
|
||
|
||
const tabs: TabItem[] = [
|
||
{
|
||
id: 'running',
|
||
label: 'Laufende Downloads',
|
||
count: runningTabCount,
|
||
icon: ArrowDownTrayIcon,
|
||
},
|
||
{
|
||
id: 'finished',
|
||
label: 'Abgeschlossene Downloads',
|
||
count: doneCount,
|
||
icon: ArchiveBoxIcon,
|
||
},
|
||
{
|
||
id: 'models',
|
||
label: 'Models',
|
||
count: modelsCount,
|
||
icon: UserGroupIcon,
|
||
},
|
||
{
|
||
id: 'categories',
|
||
label: 'Kategorien',
|
||
icon: Squares2X2Icon,
|
||
},
|
||
{
|
||
id: 'training',
|
||
label: 'Training',
|
||
icon: trainingTabRunning ? TrainingBeakerIcon : BeakerIcon,
|
||
},
|
||
{
|
||
id: 'settings',
|
||
label: 'Einstellungen',
|
||
icon: Cog6ToothIcon,
|
||
spinIcon: settingsTaskRunning,
|
||
},
|
||
]
|
||
|
||
const canStart = useMemo(() => sourceUrl.trim().length > 0, [sourceUrl])
|
||
|
||
// Cookies load/save
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
const load = async () => {
|
||
try {
|
||
const res = await apiJSON<{ cookies?: Record<string, string> }>('/api/cookies', { cache: 'no-store' })
|
||
const fromBackend = normalizeCookies(res?.cookies)
|
||
if (!cancelled) setCookies(fromBackend)
|
||
|
||
if (Object.keys(fromBackend).length === 0) {
|
||
const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
|
||
if (raw) {
|
||
try {
|
||
const obj = JSON.parse(raw) as Record<string, string>
|
||
const local = normalizeCookies(obj)
|
||
if (Object.keys(local).length > 0) {
|
||
if (!cancelled) setCookies(local)
|
||
await apiJSON('/api/cookies', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cookies: local }),
|
||
})
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
|
||
if (raw) {
|
||
try {
|
||
const obj = JSON.parse(raw) as Record<string, string>
|
||
if (!cancelled) setCookies(normalizeCookies(obj))
|
||
} catch {}
|
||
}
|
||
} finally {
|
||
if (!cancelled) setCookiesLoaded(true)
|
||
}
|
||
}
|
||
|
||
load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!cookiesLoaded) return
|
||
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
|
||
}, [cookies, cookiesLoaded])
|
||
|
||
useEffect(() => {
|
||
if (!authed) return
|
||
|
||
let es: EventSource | null = null
|
||
let fallbackTimer: number | null = null
|
||
|
||
const stopFallbackPoll = () => {
|
||
if (fallbackTimer != null) {
|
||
window.clearInterval(fallbackTimer)
|
||
fallbackTimer = null
|
||
}
|
||
}
|
||
|
||
const startFallbackPoll = () => {
|
||
if (fallbackTimer != null) return
|
||
|
||
fallbackTimer = window.setInterval(() => {
|
||
if (document.hidden) return
|
||
|
||
void loadJobs()
|
||
void loadTaskStateOnce()
|
||
|
||
if (selectedTabRef.current === 'finished') {
|
||
scheduleDoneRefresh(500)
|
||
}
|
||
}, document.hidden ? 60000 : 5000)
|
||
}
|
||
|
||
const loadTaskStateOnce = async () => {
|
||
try {
|
||
const res = await fetch('/api/tasks/status', { cache: 'no-store' as any })
|
||
if (!res.ok) return
|
||
|
||
const data = await res.json().catch(() => null)
|
||
|
||
setSettingsTaskRunning(
|
||
Boolean(
|
||
data?.anyRunning ||
|
||
data?.generateAssets?.running ||
|
||
data?.regenerateAssets?.running ||
|
||
data?.cleanup?.running
|
||
)
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const onDoneChanged = () => {
|
||
if (selectedTabRef.current === 'finished') {
|
||
scheduleDoneRefresh(900)
|
||
return
|
||
}
|
||
|
||
void loadDoneCount()
|
||
}
|
||
|
||
const onAutostart = (ev: MessageEvent) => {
|
||
try {
|
||
applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
|
||
} catch {}
|
||
}
|
||
|
||
const onFinishedPostwork = (ev: MessageEvent) => {
|
||
try {
|
||
const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
|
||
const file = String(msg?.file ?? '').trim()
|
||
if (!file) return
|
||
|
||
const state =
|
||
msg?.state === 'queued' ||
|
||
msg?.state === 'running' ||
|
||
msg?.state === 'done' ||
|
||
msg?.state === 'error' ||
|
||
msg?.state === 'missing'
|
||
? msg.state
|
||
: 'missing'
|
||
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:postwork', {
|
||
detail: {
|
||
file,
|
||
assetId: String(msg?.assetId ?? '').trim() || undefined,
|
||
queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork',
|
||
state,
|
||
phase: String(msg?.phase ?? '').trim() || undefined,
|
||
label: String(msg?.label ?? '').trim() || undefined,
|
||
error: String(msg?.error ?? '').trim() || undefined,
|
||
position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
|
||
waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
|
||
running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
|
||
maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined,
|
||
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(),
|
||
},
|
||
})
|
||
)
|
||
|
||
// ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
|
||
if (state === 'done' || state === 'error' || state === 'missing') {
|
||
bumpAssetForFile(file)
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
const onAnalysisProgress = (ev: MessageEvent) => {
|
||
try {
|
||
const msg = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
|
||
|
||
if (msg?.type !== 'analysis_progress') return
|
||
|
||
const scope = String(msg?.scope ?? '').trim()
|
||
|
||
if (scope === 'training') {
|
||
// Nur an den TrainingTab weiterreichen.
|
||
// Wichtig: Bildanalyse darf den Browser-Title NICHT auf "Training ..." setzen.
|
||
window.dispatchEvent(
|
||
new CustomEvent('app:sse:analysis', {
|
||
detail: msg,
|
||
})
|
||
)
|
||
|
||
return
|
||
}
|
||
|
||
const file = baseName(String(msg?.file ?? msg?.sourceFile ?? '').trim())
|
||
if (!file) return
|
||
|
||
const phase = String(msg?.phase ?? '').trim().toLowerCase()
|
||
const progressRaw = Number(msg?.progress ?? 0)
|
||
const progress = Number.isFinite(progressRaw)
|
||
? Math.max(0, Math.min(1, progressRaw))
|
||
: 0
|
||
|
||
const totalRaw = Number(msg?.total ?? 0)
|
||
|
||
const total =
|
||
Number.isFinite(totalRaw) && totalRaw > 0
|
||
? Math.floor(totalRaw)
|
||
: 0
|
||
|
||
const percent = Math.round(progress * 100)
|
||
const message = String(msg?.message ?? '').trim()
|
||
const runningLabel =
|
||
total > 0
|
||
? /\d{1,3}%/.test(message)
|
||
? message
|
||
: `Analyse ${percent}%`
|
||
: message || 'Analyse läuft'
|
||
|
||
const state =
|
||
phase === 'error'
|
||
? 'error'
|
||
: phase === 'done'
|
||
? 'done'
|
||
: 'running'
|
||
|
||
const label =
|
||
phase === 'error'
|
||
? 'Analyse Fehler'
|
||
: phase === 'done'
|
||
? 'Analyse fertig'
|
||
: runningLabel
|
||
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:postwork', {
|
||
detail: {
|
||
file,
|
||
assetId: stripHotPrefix(file.replace(/\.[^.]+$/, '')),
|
||
queue: 'enrich',
|
||
phase: 'analyze',
|
||
state,
|
||
label,
|
||
error: String(msg?.error ?? '').trim() || undefined,
|
||
ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts)
|
||
? msg.ts
|
||
: Date.now(),
|
||
},
|
||
})
|
||
)
|
||
|
||
if (state === 'done' || state === 'error') {
|
||
bumpAssetForFile(file)
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const onTaskState = (ev: MessageEvent) => {
|
||
try {
|
||
const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
|
||
setSettingsTaskRunning(
|
||
Boolean(
|
||
msg?.anyRunning ||
|
||
msg?.generateAssetsRunning ||
|
||
msg?.regenerateAssetsRunning ||
|
||
msg?.checkVideosRunning ||
|
||
msg?.cleanupRunning
|
||
)
|
||
)
|
||
} catch {}
|
||
}
|
||
|
||
const onTraining = (ev: MessageEvent) => {
|
||
try {
|
||
const data = JSON.parse(String(ev.data ?? 'null'))
|
||
|
||
if (data?.type !== 'training_status') return
|
||
|
||
const running = Boolean(data?.training?.running)
|
||
|
||
setTrainingTabRunning(running)
|
||
|
||
setTrainingTitleProgress(
|
||
running
|
||
? normalizeTitleProgress(data?.training?.progress ?? data?.progress)
|
||
: null
|
||
)
|
||
|
||
window.dispatchEvent(
|
||
new CustomEvent('app:sse:training', {
|
||
detail: data,
|
||
})
|
||
)
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const onNotification = (ev: MessageEvent) => {
|
||
try {
|
||
const data = JSON.parse(String(ev.data ?? 'null'))
|
||
|
||
if (data?.type !== 'notification') return
|
||
|
||
const title = String(data?.title ?? '').trim()
|
||
if (!title) return
|
||
|
||
const message = String(data?.message ?? '').trim() || undefined
|
||
const durationRaw = Number(data?.durationMs ?? 0)
|
||
const opts = Number.isFinite(durationRaw) && durationRaw > 0
|
||
? { durationMs: durationRaw }
|
||
: undefined
|
||
|
||
switch (String(data?.level ?? 'info').trim().toLowerCase()) {
|
||
case 'success':
|
||
notifyRef.current?.success(title, message, opts)
|
||
break
|
||
case 'warning':
|
||
notifyRef.current?.warning(title, message, opts)
|
||
break
|
||
case 'error':
|
||
notifyRef.current?.error(title, message, opts)
|
||
break
|
||
default:
|
||
notifyRef.current?.info(title, message, opts)
|
||
break
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
const onGlobalJob = (ev: MessageEvent) => {
|
||
onModelJobEventRef.current?.(ev)
|
||
}
|
||
|
||
void loadJobs()
|
||
void loadDoneCount()
|
||
void loadTaskStateOnce()
|
||
|
||
void loadAutostartState().catch(() => {})
|
||
|
||
es = new EventSource('/api/events/stream')
|
||
eventSourceRef.current = es
|
||
modelEventNamesRef.current = new Set()
|
||
|
||
es.onopen = () => {
|
||
stopFallbackPoll()
|
||
void loadTaskStateOnce()
|
||
}
|
||
|
||
es.onerror = () => {
|
||
startFallbackPoll()
|
||
}
|
||
|
||
es.addEventListener('doneChanged', onDoneChanged as any)
|
||
es.addEventListener('job', onGlobalJob as any)
|
||
es.addEventListener('autostart', onAutostart as any)
|
||
es.addEventListener('finishedPostwork', onFinishedPostwork as any)
|
||
es.addEventListener('taskState', onTaskState as any)
|
||
es.addEventListener('training', onTraining as any)
|
||
es.addEventListener('analysisProgress', onAnalysisProgress as any)
|
||
es.addEventListener('notification', onNotification as any)
|
||
|
||
const onVis = () => {
|
||
if (document.hidden) return
|
||
|
||
void loadJobs()
|
||
void loadTaskStateOnce()
|
||
void loadAutostartState().catch(() => {})
|
||
|
||
if (selectedTabRef.current === 'finished') {
|
||
donePrefetchRef.current = null
|
||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||
}
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', onVis)
|
||
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', onVis)
|
||
stopFallbackPoll()
|
||
|
||
if (es) {
|
||
es.removeEventListener('doneChanged', onDoneChanged as any)
|
||
es.removeEventListener('job', onGlobalJob as any)
|
||
es.removeEventListener('autostart', onAutostart as any)
|
||
es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
|
||
es.removeEventListener('taskState', onTaskState as any)
|
||
es.removeEventListener('training', onTraining as any)
|
||
es.removeEventListener('analysisProgress', onAnalysisProgress as any)
|
||
es.removeEventListener('notification', onNotification as any)
|
||
|
||
const handler = onModelJobEventRef.current
|
||
if (handler) {
|
||
for (const name of modelEventNamesRef.current) {
|
||
es.removeEventListener(name, handler as any)
|
||
}
|
||
}
|
||
|
||
es.close()
|
||
}
|
||
|
||
eventSourceRef.current = null
|
||
modelEventNamesRef.current = new Set()
|
||
}
|
||
}, [
|
||
authed,
|
||
loadJobs,
|
||
loadDoneCount,
|
||
loadPendingAutoStarts,
|
||
applyAutostartState,
|
||
loadAutostartState,
|
||
bumpAssetForFile,
|
||
scheduleDoneRefresh,
|
||
])
|
||
|
||
useEffect(() => {
|
||
const desired = new Set<string>()
|
||
|
||
// 1) Sichtbare Downloads
|
||
for (const j of jobs) {
|
||
if (!isVisibleDownloadJobForSSE(j)) continue
|
||
|
||
const key = modelEventKeyFromJob(j)
|
||
if (key) desired.add(key)
|
||
}
|
||
|
||
// 2) Sichtbare Nacharbeiten
|
||
for (const j of jobs) {
|
||
if (!isVisiblePostworkJobForSSE(j)) continue
|
||
|
||
const key = modelEventKeyFromJob(j)
|
||
if (key) desired.add(key)
|
||
}
|
||
|
||
// 3) Sichtbare Finished-Jobs der aktuellen Seite
|
||
for (const j of doneJobs) {
|
||
const key = modelEventKeyFromDoneJob(j)
|
||
if (key) desired.add(key)
|
||
}
|
||
|
||
// 4) Sichtbare Wartenden-Einträge
|
||
for (const p of pendingWatchedRooms) {
|
||
const key = String(p?.modelKey ?? '').trim().toLowerCase()
|
||
if (key) desired.add(key)
|
||
}
|
||
|
||
// 5) watched Models
|
||
for (const key of watchedModelKeysLower) {
|
||
if (key) desired.add(key)
|
||
}
|
||
|
||
// 6) fehlende Listener hinzufügen
|
||
for (const key of desired) {
|
||
ensureModelEventListener(key)
|
||
}
|
||
|
||
// 7) alles entfernen, was nicht mehr sichtbar/relevant ist
|
||
for (const key of Array.from(modelEventNamesRef.current)) {
|
||
if (!desired.has(key)) {
|
||
removeModelEventListener(key)
|
||
}
|
||
}
|
||
}, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener])
|
||
|
||
function isChaturbate(raw: string): boolean {
|
||
const norm = normalizeHttpUrl(raw)
|
||
if (!norm) return false
|
||
|
||
try {
|
||
const host = new URL(norm).hostname.replace(/^www\./i, '').toLowerCase()
|
||
return host === 'chaturbate.com' || host.endsWith('.chaturbate.com')
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function getCookie(cookiesObj: Record<string, string>, names: string[]): string | undefined {
|
||
const lowerMap = Object.fromEntries(Object.entries(cookiesObj).map(([k, v]) => [k.trim().toLowerCase(), v]))
|
||
for (const n of names) {
|
||
const v = lowerMap[n.toLowerCase()]
|
||
if (v) return v
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function hasRequiredChaturbateCookies(cookiesObj: Record<string, string>): boolean {
|
||
const cf = getCookie(cookiesObj, ['cf_clearance'])
|
||
const sess = getCookie(cookiesObj, ['sessionid', 'session_id', 'sessionId'])
|
||
return Boolean(cf && sess)
|
||
}
|
||
|
||
async function stopJob(id: string) {
|
||
try {
|
||
await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
|
||
void loadJobs()
|
||
} catch (e: any) {
|
||
notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
|
||
}
|
||
}
|
||
|
||
async function stopAllJobs() {
|
||
try {
|
||
await apiJSON('/api/record/stop-all', { method: 'POST' })
|
||
void loadJobs()
|
||
void loadPendingAutoStarts()
|
||
void loadAutostartState().catch(() => {})
|
||
} catch (e: any) {
|
||
notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
|
||
}
|
||
}
|
||
|
||
async function removeQueuedPostworkJob(id: string) {
|
||
const cleanId = String(id || '').trim()
|
||
if (!cleanId) return
|
||
|
||
const beforeJob =
|
||
jobsRef.current.find((j) => String((j as any)?.id ?? '').trim() === cleanId) ??
|
||
jobs.find((j) => String((j as any)?.id ?? '').trim() === cleanId) ??
|
||
null
|
||
|
||
const beforeFile = baseName(beforeJob?.output || '')
|
||
|
||
try {
|
||
const data = await apiJSON<{
|
||
ok?: boolean
|
||
id?: string
|
||
file?: string
|
||
output?: string
|
||
}>(`/api/record/postwork/remove?id=${encodeURIComponent(cleanId)}`, {
|
||
method: 'POST',
|
||
})
|
||
|
||
if (data?.ok !== true) {
|
||
return
|
||
}
|
||
|
||
const removedId = String(data.id || cleanId).trim()
|
||
const removedFile =
|
||
baseName(String(data.file || data.output || beforeFile || ''))
|
||
|
||
delete lastSeenAtByJobIdRef.current[removedId]
|
||
|
||
setJobs((prev) => {
|
||
const next = prev.filter((j) => {
|
||
const jid = String((j as any)?.id ?? '').trim()
|
||
return jid !== removedId
|
||
})
|
||
|
||
jobsRef.current = next
|
||
return next
|
||
})
|
||
|
||
setPlayerJob((prev) => {
|
||
if (!prev) return prev
|
||
|
||
const sameId = String((prev as any)?.id ?? '').trim() === removedId
|
||
const sameFile =
|
||
removedFile &&
|
||
baseName(prev.output || '') === removedFile
|
||
|
||
return sameId || sameFile ? null : prev
|
||
})
|
||
|
||
setDoneJobs((prev) =>
|
||
prev.filter((j) => {
|
||
const jid = String((j as any)?.id ?? '').trim()
|
||
const file = baseName(j.output || '')
|
||
|
||
if (jid && jid === removedId) return false
|
||
if (removedFile && file === removedFile) return false
|
||
|
||
return true
|
||
})
|
||
)
|
||
|
||
donePrefetchRef.current = null
|
||
|
||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||
void loadDoneCount()
|
||
} catch (e: any) {
|
||
notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e))
|
||
throw e
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const onHint = (ev: Event) => {
|
||
const e = ev as CustomEvent<{ delta?: number }>
|
||
const delta = Number(e.detail?.delta ?? 0)
|
||
|
||
if (Number.isFinite(delta) && delta !== 0) {
|
||
setDoneCount((c) => Math.max(0, c + delta))
|
||
}
|
||
}
|
||
|
||
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const onNav = (ev: Event) => {
|
||
const d = (ev as CustomEvent<any>).detail || {}
|
||
if (d.tab === 'finished') setSelectedTab('finished')
|
||
if (d.tab === 'categories') setSelectedTab('categories')
|
||
if (d.tab === 'models') setSelectedTab('models')
|
||
if (d.tab === 'training') setSelectedTab('training')
|
||
if (d.tab === 'running') setSelectedTab('running')
|
||
if (d.tab === 'settings') setSelectedTab('settings')
|
||
}
|
||
|
||
window.addEventListener('app:navigate-tab', onNav as EventListener)
|
||
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
|
||
}, [])
|
||
|
||
|
||
|
||
// ---- Player model sync (wie bei dir) ----
|
||
useEffect(() => {
|
||
if (!playerJob) {
|
||
setPlayerModel(null)
|
||
setPlayerModelKey(null)
|
||
return
|
||
}
|
||
|
||
const keyFromFile = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
|
||
setPlayerModelKey(keyFromFile || null)
|
||
|
||
const hit = keyFromFile ? modelsByKey[keyFromFile] : undefined
|
||
setPlayerModel(hit ?? null)
|
||
}, [playerJob, modelsByKey])
|
||
|
||
const manualStartInFlightRef = useRef(false)
|
||
|
||
async function onStart() {
|
||
if (manualStartInFlightRef.current) return false
|
||
|
||
manualStartInFlightRef.current = true
|
||
setBusy(true)
|
||
|
||
try {
|
||
const ok = await startUrl(sourceUrl, { silent: false, immediate: true })
|
||
if (ok) setSourceUrl('')
|
||
return ok
|
||
} finally {
|
||
manualStartInFlightRef.current = false
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
const handleAddToDownloads = useCallback(
|
||
async (job: RecordJob): Promise<boolean> => {
|
||
const raw = String(
|
||
(job as any)?.sourceUrl ??
|
||
(job as any)?.SourceURL ??
|
||
(job as any)?.sourceURL ??
|
||
(job as any)?.url ??
|
||
''
|
||
).trim()
|
||
|
||
const url0 = extractFirstUrl(raw)
|
||
if (!url0) {
|
||
notify.error('Konnte URL nicht hinzufügen', 'Für diesen Eintrag ist keine Source-URL vorhanden.')
|
||
return false
|
||
}
|
||
|
||
const norm0 = normalizeHttpUrl(url0)
|
||
if (!norm0) {
|
||
notify.error('Konnte URL nicht hinzufügen', 'Die Source-URL ist ungültig.')
|
||
return false
|
||
}
|
||
|
||
const url = canonicalizeProviderUrl(norm0)
|
||
const ok = await startUrl(url, { silent: true, immediate: true })
|
||
|
||
if (!ok) {
|
||
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
|
||
}
|
||
|
||
return ok
|
||
},
|
||
[startUrl, notify]
|
||
)
|
||
|
||
const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
|
||
const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()
|
||
|
||
if (keyLower) {
|
||
removePendingAutoStart(keyLower)
|
||
return
|
||
}
|
||
|
||
const url = String(pending?.url ?? '').trim()
|
||
if (!url) return
|
||
|
||
setPendingWatchedRooms((prev) =>
|
||
prev.filter((x) => String(x?.url ?? '').trim() !== url)
|
||
)
|
||
}, [removePendingAutoStart])
|
||
|
||
type FinishedFileActionKind = 'delete' | 'keep' | 'rename'
|
||
|
||
async function runFinishedFileAction<T>(opts: {
|
||
kind: FinishedFileActionKind
|
||
file: string
|
||
run: () => Promise<T>
|
||
onSuccess?: (result: T) => void | Promise<void>
|
||
onError?: (err: unknown) => void | Promise<void>
|
||
}) {
|
||
const { kind, file, run, onSuccess, onError } = opts
|
||
|
||
// Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
|
||
if (kind === 'delete' || kind === 'keep') {
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const, kind } })
|
||
)
|
||
}
|
||
|
||
try {
|
||
const result = await run()
|
||
|
||
if (kind === 'delete' || kind === 'keep') {
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const, kind } })
|
||
)
|
||
}
|
||
|
||
await onSuccess?.(result)
|
||
return result
|
||
} catch (e) {
|
||
if (kind === 'delete' || kind === 'keep') {
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const, kind } })
|
||
)
|
||
}
|
||
|
||
await onError?.(e)
|
||
if (e && typeof e === 'object') {
|
||
try {
|
||
Object.defineProperty(e, '__finishedDownloadsNotified', {
|
||
value: true,
|
||
configurable: true,
|
||
})
|
||
} catch {
|
||
;(e as any).__finishedDownloadsNotified = true
|
||
}
|
||
}
|
||
throw e
|
||
}
|
||
}
|
||
|
||
const doneCountRef = useRef(doneCount)
|
||
|
||
useEffect(() => {
|
||
doneCountRef.current = doneCount
|
||
}, [doneCount])
|
||
|
||
const handleDeleteJobWithUndo = useCallback(
|
||
async (job: RecordJob): Promise<void | { undoToken?: string; from?: string }> => {
|
||
const file = baseName(job.output || '')
|
||
if (!file) return
|
||
|
||
try {
|
||
const data = await runFinishedFileAction<{ undoToken?: string; from?: string }>({
|
||
kind: 'delete',
|
||
file,
|
||
run: () =>
|
||
apiJSON<{ undoToken?: string }>(
|
||
`/api/record/delete?file=${encodeURIComponent(file)}`,
|
||
{ method: 'POST' }
|
||
),
|
||
onSuccess: async () => {
|
||
window.setTimeout(() => {
|
||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||
|
||
donePrefetchRef.current = null
|
||
|
||
scheduleDoneRefresh(900)
|
||
}, 320)
|
||
},
|
||
onError: async () => {
|
||
notify.error('Löschen fehlgeschlagen', file)
|
||
},
|
||
})
|
||
|
||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
||
const from = typeof data?.from === 'string' ? data.from : undefined
|
||
|
||
return undoToken ? { undoToken, from } : {}
|
||
} catch (e) {
|
||
throw e
|
||
}
|
||
},
|
||
[
|
||
runFinishedFileAction,
|
||
notify,
|
||
scheduleDoneRefresh,
|
||
]
|
||
)
|
||
|
||
const handleKeepJob = useCallback(
|
||
async (job: RecordJob): Promise<void | { newFile?: string }> => {
|
||
const file = baseName(job.output || '')
|
||
if (!file) return
|
||
|
||
try {
|
||
const data = await runFinishedFileAction<{ newFile?: string }>({
|
||
kind: 'keep',
|
||
file,
|
||
run: () =>
|
||
apiJSON<{ newFile?: string }>(
|
||
`/api/record/keep?file=${encodeURIComponent(file)}`,
|
||
{ method: 'POST' }
|
||
),
|
||
onSuccess: async () => {
|
||
window.setTimeout(() => {
|
||
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
|
||
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
|
||
|
||
donePrefetchRef.current = null
|
||
|
||
scheduleDoneRefresh(900)
|
||
}, 320)
|
||
},
|
||
onError: async () => {
|
||
notify.error('Keep fehlgeschlagen', file)
|
||
},
|
||
})
|
||
|
||
return data
|
||
} catch (e) {
|
||
throw e
|
||
}
|
||
},
|
||
[runFinishedFileAction, notify, scheduleDoneRefresh]
|
||
)
|
||
|
||
const handleToggleHot = useCallback(
|
||
async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
|
||
const file = baseName(job.output || '')
|
||
if (!file) return
|
||
|
||
try {
|
||
// ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
|
||
window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
|
||
await new Promise((r) => window.setTimeout(r, 60))
|
||
|
||
const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
|
||
`/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
|
||
{ method: 'POST' }
|
||
)
|
||
|
||
const oldFile = baseName(res.oldFile || file) || file
|
||
const newFile = baseName(res.newFile || '') || ''
|
||
if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück')
|
||
|
||
// ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details)
|
||
window.dispatchEvent(
|
||
new CustomEvent('finished-downloads:rename', {
|
||
detail: { oldFile, newFile },
|
||
})
|
||
)
|
||
|
||
const apply = (out: string) => replaceBasename(out || '', newFile)
|
||
|
||
// ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
|
||
setPlayerJob((prev) => {
|
||
if (!prev) return prev
|
||
const match = prev.id === job.id || baseName(prev.output || '') === oldFile
|
||
return match ? { ...prev, output: apply(prev.output || '') } : prev
|
||
})
|
||
|
||
// ✅ doneJobs über ID (Fallback basename)
|
||
setDoneJobs((prev) =>
|
||
prev.map((j) => {
|
||
const match = j.id === job.id || baseName(j.output || '') === oldFile
|
||
return match ? { ...j, output: apply(j.output || '') } : j
|
||
})
|
||
)
|
||
|
||
// ✅ jobs (/record/list) über ID (Fallback basename)
|
||
setJobs((prev) =>
|
||
prev.map((j) => {
|
||
const match = j.id === job.id || baseName(j.output || '') === oldFile
|
||
return match ? { ...j, output: apply(j.output || '') } : j
|
||
})
|
||
)
|
||
|
||
donePrefetchRef.current = null
|
||
|
||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||
void loadDoneCount()
|
||
|
||
return res
|
||
} catch (e: any) {
|
||
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
|
||
return
|
||
}
|
||
},
|
||
[notify, loadDoneCount]
|
||
)
|
||
|
||
const handleDeleteJob = useCallback(
|
||
async (job: RecordJob): Promise<void> => {
|
||
await handleDeleteJobWithUndo(job)
|
||
},
|
||
[handleDeleteJobWithUndo]
|
||
)
|
||
|
||
// --- flags patch (wie bei dir) ---
|
||
async function patchModelFlags(patch: any): Promise<any | null> {
|
||
const res = await fetch('/api/models/flags', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch),
|
||
})
|
||
|
||
if (res.status === 204) return null
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
const removeModelCache = useCallback((id: string) => {
|
||
const cur = modelsCacheRef.current
|
||
if (!cur || !id) return
|
||
cur.ts = Date.now()
|
||
cur.list = cur.list.filter((x) => x.id !== id)
|
||
}, [])
|
||
|
||
// ✅ verhindert Doppel-Requests pro Model
|
||
const flagsInFlightRef = useRef<Record<string, true>>({})
|
||
|
||
// ✅ handleToggleFavorite (komplett)
|
||
const handleToggleFavorite = useCallback(
|
||
async (job: RecordJob) => {
|
||
const file = baseName(job.output || '')
|
||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||
|
||
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||
if (!modelKey) {
|
||
notify.error('Favorit umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||
return
|
||
}
|
||
|
||
const guardKey = modelKey
|
||
if (flagsInFlightRef.current[guardKey]) return
|
||
flagsInFlightRef.current[guardKey] = true
|
||
|
||
const prev =
|
||
modelsByKey[modelKey] ??
|
||
({
|
||
id: '',
|
||
input: '',
|
||
host: host || undefined,
|
||
modelKey,
|
||
watching: false,
|
||
favorite: false,
|
||
liked: null,
|
||
isUrl: false,
|
||
} as StoredModel)
|
||
|
||
const nextFav = !Boolean(prev.favorite)
|
||
|
||
const optimistic: StoredModel = {
|
||
...prev,
|
||
host: prev.host || host || undefined,
|
||
modelKey,
|
||
favorite: nextFav,
|
||
liked: nextFav ? false : prev.liked,
|
||
}
|
||
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||
upsertModelCache(optimistic)
|
||
if (sameAsPlayer) setPlayerModel(optimistic)
|
||
|
||
try {
|
||
const updated = await patchModelFlags({
|
||
...(prev.id ? { id: prev.id } : {}),
|
||
host: optimistic.host || '',
|
||
modelKey,
|
||
favorite: nextFav,
|
||
...(nextFav ? { liked: false } : {}),
|
||
})
|
||
|
||
if (!updated) {
|
||
setModelsByKey((p) => {
|
||
const { [modelKey]: _drop, ...rest } = p
|
||
return rest
|
||
})
|
||
if (prev.id) removeModelCache(prev.id)
|
||
if (sameAsPlayer) setPlayerModel(null)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||
)
|
||
return
|
||
}
|
||
|
||
const k = lower(updated.modelKey || modelKey)
|
||
const mergedUpdated = k
|
||
? mergeStoredModel(modelsByKeyRef.current[k], updated)
|
||
: updated
|
||
|
||
if (k) {
|
||
setModelsByKey((p) => ({
|
||
...p,
|
||
[k]: mergeStoredModel(p[k], updated),
|
||
}))
|
||
}
|
||
|
||
upsertModelCache(mergedUpdated)
|
||
if (sameAsPlayer) setPlayerModel(mergedUpdated)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
|
||
)
|
||
} catch (e: any) {
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||
upsertModelCache(prev)
|
||
if (sameAsPlayer) setPlayerModel(prev)
|
||
notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
|
||
} finally {
|
||
delete flagsInFlightRef.current[guardKey]
|
||
}
|
||
},
|
||
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||
)
|
||
|
||
|
||
// ✅ handleToggleLike (komplett)
|
||
const handleToggleLike = useCallback(
|
||
async (job: RecordJob) => {
|
||
const file = baseName(job.output || '')
|
||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||
|
||
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||
if (!modelKey) {
|
||
notify.error('Like umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||
return
|
||
}
|
||
|
||
const guardKey = modelKey
|
||
if (flagsInFlightRef.current[guardKey]) return
|
||
flagsInFlightRef.current[guardKey] = true
|
||
|
||
const prev =
|
||
modelsByKey[modelKey] ??
|
||
({
|
||
id: '',
|
||
input: '',
|
||
host: host || undefined,
|
||
modelKey,
|
||
watching: false,
|
||
favorite: false,
|
||
liked: null,
|
||
isUrl: false,
|
||
} as StoredModel)
|
||
|
||
const nextLiked = !(prev.liked === true)
|
||
|
||
const optimistic: StoredModel = {
|
||
...prev,
|
||
host: prev.host || host || undefined,
|
||
modelKey,
|
||
liked: nextLiked,
|
||
favorite: nextLiked ? false : prev.favorite,
|
||
}
|
||
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||
upsertModelCache(optimistic)
|
||
if (sameAsPlayer) setPlayerModel(optimistic)
|
||
|
||
try {
|
||
const updated = await patchModelFlags({
|
||
...(prev.id ? { id: prev.id } : {}),
|
||
host: optimistic.host || '',
|
||
modelKey,
|
||
liked: nextLiked,
|
||
...(nextLiked ? { favorite: false } : {}),
|
||
})
|
||
|
||
if (!updated) {
|
||
setModelsByKey((p) => {
|
||
const { [modelKey]: _drop, ...rest } = p
|
||
return rest
|
||
})
|
||
if (prev.id) removeModelCache(prev.id)
|
||
if (sameAsPlayer) setPlayerModel(null)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||
)
|
||
return
|
||
}
|
||
|
||
const k = lower(updated.modelKey || modelKey)
|
||
const mergedUpdated = k
|
||
? mergeStoredModel(modelsByKeyRef.current[k], updated)
|
||
: updated
|
||
|
||
if (k) {
|
||
setModelsByKey((p) => ({
|
||
...p,
|
||
[k]: mergeStoredModel(p[k], updated),
|
||
}))
|
||
}
|
||
|
||
upsertModelCache(mergedUpdated)
|
||
if (sameAsPlayer) setPlayerModel(mergedUpdated)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
|
||
)
|
||
} catch (e: any) {
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||
upsertModelCache(prev)
|
||
if (sameAsPlayer) setPlayerModel(prev)
|
||
notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
|
||
} finally {
|
||
delete flagsInFlightRef.current[guardKey]
|
||
}
|
||
},
|
||
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||
)
|
||
|
||
|
||
// ✅ handleToggleWatch (komplett)
|
||
const handleToggleWatch = useCallback(
|
||
async (job: RecordJob) => {
|
||
const file = baseName(job.output || '')
|
||
const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)
|
||
|
||
const { modelKey, host } = modelKeyAndHostFromJob(job)
|
||
if (!modelKey) {
|
||
notify.error('Watched umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
|
||
return
|
||
}
|
||
|
||
const guardKey = modelKey
|
||
if (flagsInFlightRef.current[guardKey]) return
|
||
flagsInFlightRef.current[guardKey] = true
|
||
|
||
const prev =
|
||
modelsByKey[modelKey] ??
|
||
({
|
||
id: '',
|
||
input: '',
|
||
host: host || undefined,
|
||
modelKey,
|
||
watching: false,
|
||
favorite: false,
|
||
liked: null,
|
||
isUrl: false,
|
||
} as StoredModel)
|
||
|
||
const nextWatching = !Boolean(prev.watching)
|
||
|
||
const optimistic: StoredModel = {
|
||
...prev,
|
||
host: prev.host || host || undefined,
|
||
modelKey,
|
||
watching: nextWatching,
|
||
}
|
||
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
|
||
upsertModelCache(optimistic)
|
||
if (sameAsPlayer) setPlayerModel(optimistic)
|
||
|
||
try {
|
||
const updated = await patchModelFlags({
|
||
...(prev.id ? { id: prev.id } : {}),
|
||
host: optimistic.host || '',
|
||
modelKey,
|
||
watched: nextWatching,
|
||
})
|
||
|
||
if (!updated) {
|
||
setModelsByKey((p) => {
|
||
const { [modelKey]: _drop, ...rest } = p
|
||
return rest
|
||
})
|
||
if (prev.id) removeModelCache(prev.id)
|
||
if (sameAsPlayer) setPlayerModel(null)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
|
||
)
|
||
return
|
||
}
|
||
|
||
const k = lower(updated.modelKey || modelKey)
|
||
const mergedUpdated = k
|
||
? mergeStoredModel(modelsByKeyRef.current[k], updated)
|
||
: updated
|
||
|
||
if (k) {
|
||
setModelsByKey((p) => ({
|
||
...p,
|
||
[k]: mergeStoredModel(p[k], updated),
|
||
}))
|
||
}
|
||
|
||
upsertModelCache(mergedUpdated)
|
||
if (sameAsPlayer) setPlayerModel(mergedUpdated)
|
||
window.dispatchEvent(
|
||
new CustomEvent('models-changed', { detail: { model: mergedUpdated } })
|
||
)
|
||
} catch (e: any) {
|
||
setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
|
||
upsertModelCache(prev)
|
||
if (sameAsPlayer) setPlayerModel(prev)
|
||
notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
|
||
} finally {
|
||
delete flagsInFlightRef.current[guardKey]
|
||
}
|
||
},
|
||
[notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
|
||
)
|
||
|
||
// Clipboard auto add/start (wie bei dir)
|
||
useEffect(() => {
|
||
if (!autoAddEnabled && !autoStartEnabled) return
|
||
if (!navigator.clipboard?.readText) return
|
||
|
||
let cancelled = false
|
||
let inFlight = false
|
||
let timer: number | null = null
|
||
let listenersAttached = false
|
||
|
||
const checkClipboard = async () => {
|
||
if (cancelled || inFlight) return
|
||
inFlight = true
|
||
try {
|
||
const text = await navigator.clipboard.readText()
|
||
const url0 = extractFirstUrl(text)
|
||
if (!url0) return
|
||
|
||
const norm0 = normalizeHttpUrl(url0)
|
||
if (!norm0) return
|
||
|
||
const provider = getProviderFromNormalizedUrl(norm0)
|
||
if (!provider) return
|
||
|
||
const url = canonicalizeProviderUrl(norm0)
|
||
|
||
if (url === lastClipboardUrlRef.current) return
|
||
lastClipboardUrlRef.current = url
|
||
|
||
if (autoAddEnabled) setSourceUrl(url)
|
||
|
||
if (autoStartEnabled) {
|
||
void startUrl(url, { silent: false, immediate: true })
|
||
}
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
inFlight = false
|
||
}
|
||
}
|
||
|
||
const kick = () => {
|
||
void checkClipboard()
|
||
}
|
||
|
||
const schedule = (ms: number) => {
|
||
if (cancelled) return
|
||
timer = window.setTimeout(async () => {
|
||
await checkClipboard()
|
||
schedule(document.hidden ? 5000 : 1500)
|
||
}, ms)
|
||
}
|
||
|
||
const startClipboardPolling = async () => {
|
||
const allowed = await canReadClipboardWithoutPrompt()
|
||
if (cancelled || !allowed) return
|
||
|
||
listenersAttached = true
|
||
window.addEventListener('focus', kick)
|
||
document.addEventListener('visibilitychange', kick)
|
||
|
||
schedule(0)
|
||
}
|
||
|
||
void startClipboardPolling()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
if (timer) window.clearTimeout(timer)
|
||
if (listenersAttached) {
|
||
window.removeEventListener('focus', kick)
|
||
document.removeEventListener('visibilitychange', kick)
|
||
}
|
||
}
|
||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||
|
||
const isTrainingTab = selectedTab === 'training'
|
||
const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab)
|
||
|
||
useEffect(() => {
|
||
if (!isTrainingTab) {
|
||
setTrainingImageExpanded(false)
|
||
}
|
||
}, [isTrainingTab])
|
||
|
||
useEffect(() => {
|
||
if (isTrainingTab) {
|
||
setTrainingTabMounted(true)
|
||
}
|
||
}, [isTrainingTab])
|
||
|
||
if (!authChecked) {
|
||
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||
}
|
||
|
||
if (!authed) {
|
||
return <LoginPage onLoggedIn={checkAuth} />
|
||
}
|
||
|
||
const formatHeaderStatValue = (value: unknown) => {
|
||
const n = Number(value)
|
||
|
||
if (!Number.isFinite(n)) return '0'
|
||
if (n >= 10000) return `${Math.round(n / 1000)}k`
|
||
if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
|
||
|
||
return String(Math.max(0, Math.floor(n)))
|
||
}
|
||
|
||
const headerShellClass =
|
||
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] max-lg:[@media_(orientation:landscape)]:rounded-xl max-lg:[@media_(orientation:landscape)]:p-2'
|
||
|
||
const headerStatClass =
|
||
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||
|
||
const mobileHeaderStatClass =
|
||
'flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-gray-200/70 bg-white/75 px-1.5 text-[10px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||
|
||
const headerStatValueClass =
|
||
'inline-block min-w-[3ch] text-right tabular-nums text-gray-950 dark:text-white'
|
||
|
||
const headerActionClass =
|
||
'h-9 shrink-0 rounded-xl px-3 text-sm max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:rounded-lg max-lg:[@media_(orientation:landscape)]:px-2.5 max-lg:[@media_(orientation:landscape)]:text-xs'
|
||
|
||
const headerStatItems = [
|
||
{
|
||
label: 'Online',
|
||
value: onlineModelsCount,
|
||
icon: SignalIcon,
|
||
},
|
||
{
|
||
label: 'Watched',
|
||
value: onlineWatchedModelsCount,
|
||
icon: EyeIcon,
|
||
},
|
||
{
|
||
label: 'Favoriten',
|
||
value: onlineFavCount,
|
||
icon: StarIcon,
|
||
},
|
||
{
|
||
label: 'Likes',
|
||
value: onlineLikedCount,
|
||
icon: HeartIcon,
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div
|
||
className={[
|
||
'min-h-[100dvh] lg:h-[100dvh] lg:overflow-hidden',
|
||
'bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100',
|
||
].join(' ')}
|
||
>
|
||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
|
||
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
|
||
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
|
||
</div>
|
||
|
||
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
|
||
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
|
||
<div className="mx-auto max-w-7xl p-3 max-lg:[@media_(orientation:landscape)]:p-1.5">
|
||
<div className={headerShellClass}>
|
||
<div className="grid gap-2.5 max-lg:[@media_(orientation:landscape)]:gap-1.5">
|
||
<div className="flex flex-col gap-2.5 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-start max-lg:[@media_(orientation:landscape)]:justify-between max-lg:[@media_(orientation:landscape)]:gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex min-w-0 items-center gap-2">
|
||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:w-8 max-lg:[@media_(orientation:landscape)]:rounded-lg">
|
||
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
||
</div>
|
||
|
||
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg max-lg:[@media_(orientation:landscape)]:text-base">
|
||
Recorder
|
||
</h1>
|
||
|
||
<div className="hidden min-w-0 flex-wrap items-center gap-1.5 lg:flex">
|
||
{headerStatItems.map((item) => {
|
||
const Icon = item.icon
|
||
|
||
return (
|
||
<span
|
||
key={item.label}
|
||
className={headerStatClass}
|
||
title={item.label}
|
||
>
|
||
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
||
|
||
<span className={headerStatValueClass}>
|
||
{formatHeaderStatValue(item.value)}
|
||
</span>
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:hidden max-lg:[@media_(orientation:landscape)]:hidden">
|
||
{headerStatItems.map((item) => {
|
||
const Icon = item.icon
|
||
|
||
return (
|
||
<span
|
||
key={item.label}
|
||
className={mobileHeaderStatClass}
|
||
title={item.label}
|
||
>
|
||
<Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden="true" />
|
||
|
||
<span className={headerStatValueClass}>
|
||
{formatHeaderStatValue(item.value)}
|
||
</span>
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px] max-lg:[@media_(orientation:landscape)]:hidden">
|
||
<LastUpdatedText
|
||
enabled={Boolean(recSettings.useChaturbateApi)}
|
||
fetchedAt={cbOnlineFetchedAt}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:justify-end sm:flex-row sm:items-center lg:justify-end">
|
||
{showPerfMon ? (
|
||
<PerformanceMonitor
|
||
mode="inline"
|
||
compact
|
||
className="w-full max-lg:[@media_(orientation:landscape)]:w-[300px] sm:w-[300px] lg:w-[360px]"
|
||
/>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-1.5 max-lg:[@media_(orientation:landscape)]:flex max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:gap-2 sm:flex sm:items-center sm:gap-2">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => setCookieModalOpen(true)}
|
||
className={`${headerActionClass} w-full sm:w-auto`}
|
||
>
|
||
<span className="inline-flex items-center justify-center gap-1.5">
|
||
<CircleStackIcon className="h-4 w-4" aria-hidden="true" />
|
||
Cookies
|
||
</span>
|
||
</Button>
|
||
|
||
<Button
|
||
variant="soft"
|
||
color="red"
|
||
onClick={logout}
|
||
className={`${headerActionClass} w-full sm:w-auto`}
|
||
>
|
||
<span className="inline-flex items-center justify-center gap-1.5">
|
||
<ArrowRightOnRectangleIcon className="h-4 w-4" aria-hidden="true" />
|
||
Abmelden
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid min-w-0 gap-2 max-lg:[@media_(orientation:landscape)]:grid-cols-[minmax(0,1fr)_7.5rem] max-lg:[@media_(orientation:landscape)]:items-center lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||
<div className="relative min-w-0">
|
||
<label className="sr-only">Source URL</label>
|
||
<TextInput
|
||
ref={sourceUrlInputRef}
|
||
value={sourceUrl}
|
||
size="sm"
|
||
onChange={(e: { target: { value: SetStateAction<string> } }) =>
|
||
setSourceUrl(e.target.value)
|
||
}
|
||
selectAllOnFocus
|
||
selectAllOnMouseDown
|
||
placeholder="Stream-URL einfügen…"
|
||
/>
|
||
</div>
|
||
|
||
<Button
|
||
variant="primary"
|
||
onClick={onStart}
|
||
disabled={!canStart || busy}
|
||
className="h-9 w-full rounded-xl px-4 sm:w-auto"
|
||
>
|
||
<span className="inline-flex items-center justify-center gap-1.5">
|
||
<PlayIcon className="h-4 w-4" aria-hidden="true" />
|
||
{busy ? 'Starte…' : 'Start'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{error ? (
|
||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 break-words">{error}</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="shrink-0 rounded-lg px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
|
||
onClick={() => setError(null)}
|
||
aria-label="Fehlermeldung schließen"
|
||
title="Schließen"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
|
||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||
⚠️ Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
|
||
</div>
|
||
) : null}
|
||
|
||
{busy ? (
|
||
<ProgressBar label="Starte Download…" indeterminate />
|
||
) : null}
|
||
|
||
<div className="pt-1">
|
||
<Tabs
|
||
tabs={tabs}
|
||
value={selectedTab}
|
||
onChange={setSelectedTab}
|
||
ariaLabel="Tabs"
|
||
variant="barUnderline"
|
||
desktopBreakpoint="lg"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main
|
||
data-app-scroll-area="true"
|
||
className={[
|
||
'w-full lg:min-h-0',
|
||
isTrainingTab
|
||
? 'pb-4 lg:flex-1 lg:overflow-hidden'
|
||
: 'lg:flex-1 lg:overflow-y-auto lg:overscroll-contain lg:[scrollbar-gutter:stable_both-edges]',
|
||
].join(' ')}
|
||
>
|
||
<div
|
||
className={[
|
||
'w-full p-3',
|
||
selectedTab === 'finished'
|
||
? 'max-w-none px-0'
|
||
: isTrainingTab && trainingImageExpanded
|
||
? 'max-w-none px-2 sm:px-3 lg:px-4'
|
||
: 'mx-auto max-w-7xl',
|
||
].join(' ')}
|
||
>
|
||
{selectedTab === 'running' ? (
|
||
<Downloads
|
||
jobs={jobs}
|
||
modelsByKey={modelsByKey}
|
||
roomStatusByModelKey={roomStatusByModelKey}
|
||
pending={pendingWatchedRooms}
|
||
autostartState={autostartState}
|
||
onRefreshAutostartState={async (next?: AutostartState) => {
|
||
try {
|
||
if (next) {
|
||
applyAutostartState(next)
|
||
return
|
||
}
|
||
|
||
await loadAutostartState()
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}}
|
||
onOpenPlayer={openPlayer}
|
||
onStopJob={stopJob}
|
||
onStopAllJobs={stopAllJobs}
|
||
onRemoveQueuedPostworkJob={removeQueuedPostworkJob}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
onToggleLike={handleToggleLike}
|
||
onToggleWatch={handleToggleWatch}
|
||
onAddToDownloads={handleAddToDownloads}
|
||
onRemovePending={handleRemovePendingWatchedRoom}
|
||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||
enableConcurrentDownloadsLimit={Boolean(recSettings.enableConcurrentDownloadsLimit)}
|
||
maxConcurrentDownloads={Number(recSettings.maxConcurrentDownloads ?? 20)}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedTab === 'finished' ? (
|
||
<FinishedDownloads
|
||
jobs={jobs}
|
||
modelsByKey={modelsByKey}
|
||
doneJobs={doneJobs}
|
||
doneTotal={doneCount}
|
||
page={donePage}
|
||
pageSize={donePageSize}
|
||
onPageSizeChange={(size) => {
|
||
setDonePageSize(size)
|
||
setDonePage(1)
|
||
donePrefetchRef.current = null
|
||
}}
|
||
onPageChange={setDonePage}
|
||
onOpenPlayer={openPlayer}
|
||
onDeleteJob={handleDeleteJobWithUndo}
|
||
onToggleHot={handleToggleHot}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
onToggleLike={handleToggleLike}
|
||
onToggleWatch={handleToggleWatch}
|
||
onKeepJob={handleKeepJob}
|
||
onSplitJob={openSplitModal}
|
||
onAddToDownloads={handleAddToDownloads}
|
||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||
pauseTeasers={Boolean(detailsModelKey) || splitModalOpen || Boolean(playerJob)}
|
||
assetNonce={assetNonce}
|
||
assetNonceByFile={assetNonceByFile}
|
||
sortMode={doneSort}
|
||
onSortModeChange={(m) => {
|
||
setDoneSort(m)
|
||
setDonePage(1)
|
||
}}
|
||
loadMode="paged"
|
||
includeKeep={includeKeep}
|
||
onIncludeKeepChange={(checked) => {
|
||
setIncludeKeep(checked)
|
||
setDonePage(1)
|
||
donePrefetchRef.current = null
|
||
}}
|
||
splitProgressByFile={splitProgressByFile}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedTab === 'models' ? (
|
||
<ModelsTab
|
||
onAddToDownloads={handleAddToDownloads}
|
||
onModelsCountChange={handleModelsCountChange}
|
||
/>
|
||
) : null}
|
||
|
||
{trainingTabMounted ? (
|
||
<div
|
||
className={isTrainingTab ? undefined : 'hidden'}
|
||
aria-hidden={isTrainingTab ? undefined : true}
|
||
>
|
||
<TrainingTab
|
||
active={isTrainingTab}
|
||
onTrainingRunningChange={setTrainingTabRunning}
|
||
onImageExpandedChange={setTrainingImageExpanded}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||
|
||
{selectedTab === 'settings' ? (
|
||
<Settings
|
||
onAssetsGenerated={bumpAssets}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</main>
|
||
|
||
<CookieModal
|
||
open={cookieModalOpen}
|
||
onClose={() => setCookieModalOpen(false)}
|
||
initialCookies={initialCookies}
|
||
onApply={(list) => {
|
||
const normalized = normalizeCookies(Object.fromEntries(list.map((c) => [c.name, c.value] as const)))
|
||
setCookies(normalized)
|
||
|
||
void apiJSON('/api/cookies', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ cookies: normalized }),
|
||
}).catch(() => {})
|
||
}}
|
||
/>
|
||
|
||
<ModelDetails
|
||
open={Boolean(detailsModelKey)}
|
||
modelKey={detailsModelKey}
|
||
onClose={() => setDetailsModelKey(null)}
|
||
onOpenPlayer={openPlayer}
|
||
runningJobs={jobs}
|
||
cookies={cookies}
|
||
blurPreviews={recSettings.blurPreviews}
|
||
|
||
// ✅ neu: gleiche Teaser-Settings wie FinishedDownloads
|
||
teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
|
||
teaserAudio={Boolean(recSettings.teaserAudio)}
|
||
|
||
onToggleHot={handleToggleHot}
|
||
onDelete={handleDeleteJob}
|
||
onKeep={handleKeepJob} // ✅ neu
|
||
onToggleFavorite={handleToggleFavorite}
|
||
onToggleLike={handleToggleLike}
|
||
onToggleWatch={handleToggleWatch}
|
||
onStopJob={stopJob}
|
||
/>
|
||
|
||
<VideoSplitModal
|
||
key={splitModalKey}
|
||
open={splitModalOpen}
|
||
job={splitJob}
|
||
videoSrc={videoSrcFromJob(splitJob)}
|
||
timelinePreviewCount={8}
|
||
onClose={() => {
|
||
setSplitModalOpen(false)
|
||
setSplitJob(null)
|
||
}}
|
||
onApply={async ({ job, splits, segments, deleteOriginal, onProgress }) => {
|
||
const file = baseName(job.output || '')
|
||
if (!file) {
|
||
throw new Error('Kein Dateiname vorhanden.')
|
||
}
|
||
|
||
const payload =
|
||
Array.isArray(segments) && segments.length > 0
|
||
? {
|
||
file,
|
||
segments: segments.map((seg) => ({
|
||
start: Number(seg.start),
|
||
end: Number(seg.end),
|
||
})),
|
||
deleteOriginal,
|
||
}
|
||
: {
|
||
file,
|
||
splits,
|
||
deleteOriginal,
|
||
}
|
||
|
||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n))
|
||
|
||
const totalFallback =
|
||
Array.isArray(segments) && segments.length > 0
|
||
? segments.length
|
||
: Math.max(0, splits.length + 1)
|
||
|
||
const selectedSegmentForEvent = (ev: any) => {
|
||
const pos = Number(ev?.current ?? 0)
|
||
if (!Number.isFinite(pos) || pos < 1 || pos > segments.length) {
|
||
return null
|
||
}
|
||
return segments[pos - 1] ?? null
|
||
}
|
||
|
||
type ModalSplitProgress = Parameters<NonNullable<typeof onProgress>>[0]
|
||
|
||
const publish = (progress: ModalSplitProgress) => {
|
||
pushSplitProgress(
|
||
file,
|
||
progress as Omit<BackgroundSplitProgress, 'sourceFile' | 'startedAtMs'>
|
||
)
|
||
|
||
onProgress?.(progress)
|
||
}
|
||
|
||
return await (async () => {
|
||
let buffer = ''
|
||
let finalData: any = null
|
||
let total = totalFallback
|
||
|
||
try {
|
||
publish({
|
||
phase: 'preparing',
|
||
total: totalFallback,
|
||
current: 0,
|
||
completed: 0,
|
||
segmentProgress: 0,
|
||
overallProgress: 0,
|
||
message: 'Split wird vorbereitet…',
|
||
})
|
||
|
||
const res = await fetch('/api/record/split?stream=1', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '')
|
||
throw new Error(text || `HTTP ${res.status}`)
|
||
}
|
||
|
||
if (!res.body) {
|
||
throw new Error('Split-Stream konnte nicht gelesen werden.')
|
||
}
|
||
|
||
const reader = res.body.getReader()
|
||
const decoder = new TextDecoder()
|
||
|
||
const applyEvent = (ev: any) => {
|
||
const type = String(ev?.type ?? '').trim()
|
||
|
||
switch (type) {
|
||
case 'start': {
|
||
total = Math.max(1, Number(ev?.total ?? totalFallback))
|
||
publish({
|
||
phase: 'preparing',
|
||
total,
|
||
current: 0,
|
||
completed: 0,
|
||
segmentProgress: 0,
|
||
overallProgress: 0,
|
||
message: String(ev?.message || 'Split gestartet'),
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'segment_start': {
|
||
const current = Math.max(1, Number(ev?.current ?? 1))
|
||
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||
const currentSegment = selectedSegmentForEvent(ev)
|
||
|
||
publish({
|
||
phase: 'splitting',
|
||
total: evTotal,
|
||
current,
|
||
completed: current - 1,
|
||
segmentProgress: 0,
|
||
overallProgress: clamp01((current - 1) / evTotal),
|
||
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
|
||
segment: currentSegment
|
||
? {
|
||
index: currentSegment.index,
|
||
label: currentSegment.label,
|
||
start: currentSegment.start,
|
||
end: currentSegment.end,
|
||
duration: currentSegment.duration,
|
||
}
|
||
: {
|
||
index: current,
|
||
label: `Segment ${current}`,
|
||
start: Number(ev?.start ?? 0),
|
||
end: Number(ev?.end ?? 0),
|
||
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||
},
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'segment_progress': {
|
||
const current = Math.max(1, Number(ev?.current ?? 1))
|
||
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||
const segmentProgress = clamp01(Number(ev?.progress ?? 0))
|
||
const overallProgress = clamp01(
|
||
Number.isFinite(Number(ev?.overall))
|
||
? Number(ev?.overall)
|
||
: (current - 1 + segmentProgress) / evTotal
|
||
)
|
||
const currentSegment = selectedSegmentForEvent(ev)
|
||
|
||
publish({
|
||
phase: 'splitting',
|
||
total: evTotal,
|
||
current,
|
||
completed: current - 1,
|
||
segmentProgress,
|
||
overallProgress,
|
||
message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`),
|
||
segment: currentSegment
|
||
? {
|
||
index: currentSegment.index,
|
||
label: currentSegment.label,
|
||
start: currentSegment.start,
|
||
end: currentSegment.end,
|
||
duration: currentSegment.duration,
|
||
}
|
||
: {
|
||
index: current,
|
||
label: `Segment ${current}`,
|
||
start: Number(ev?.start ?? 0),
|
||
end: Number(ev?.end ?? 0),
|
||
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||
},
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'segment_done': {
|
||
const current = Math.max(1, Number(ev?.current ?? 1))
|
||
const evTotal = Math.max(1, Number(ev?.total ?? total))
|
||
const currentSegment = selectedSegmentForEvent(ev)
|
||
|
||
publish({
|
||
phase: current >= evTotal ? 'done' : 'splitting',
|
||
total: evTotal,
|
||
current,
|
||
completed: current,
|
||
segmentProgress: 1,
|
||
overallProgress: clamp01(current / evTotal),
|
||
message: String(ev?.message || `Segment ${current}/${evTotal} fertig`),
|
||
segment: currentSegment
|
||
? {
|
||
index: currentSegment.index,
|
||
label: currentSegment.label,
|
||
start: currentSegment.start,
|
||
end: currentSegment.end,
|
||
duration: currentSegment.duration,
|
||
}
|
||
: {
|
||
index: current,
|
||
label: `Segment ${current}`,
|
||
start: Number(ev?.start ?? 0),
|
||
end: Number(ev?.end ?? 0),
|
||
duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)),
|
||
},
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'error': {
|
||
throw new Error(String(ev?.message || 'Split fehlgeschlagen'))
|
||
}
|
||
|
||
case 'done': {
|
||
finalData = ev
|
||
publish({
|
||
phase: 'done',
|
||
total: Math.max(1, Number(ev?.total ?? total)),
|
||
current: Math.max(1, Number(ev?.total ?? total)),
|
||
completed: Math.max(1, Number(ev?.total ?? total)),
|
||
segmentProgress: 1,
|
||
overallProgress: 1,
|
||
message: String(ev?.message || 'Split abgeschlossen'),
|
||
})
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
while (true) {
|
||
const { value, done } = await reader.read()
|
||
if (done) break
|
||
|
||
buffer += decoder.decode(value, { stream: true })
|
||
|
||
const lines = buffer.split('\n')
|
||
buffer = lines.pop() ?? ''
|
||
|
||
for (const rawLine of lines) {
|
||
const line = rawLine.trim()
|
||
if (!line) continue
|
||
applyEvent(JSON.parse(line))
|
||
}
|
||
}
|
||
|
||
buffer += decoder.decode()
|
||
if (buffer.trim()) {
|
||
applyEvent(JSON.parse(buffer.trim()))
|
||
}
|
||
|
||
if (!finalData?.ok) {
|
||
throw new Error('Split wurde nicht erfolgreich abgeschlossen.')
|
||
}
|
||
|
||
bumpAssets()
|
||
donePrefetchRef.current = null
|
||
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
|
||
void loadDoneCount()
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof Error ? err.message : 'Split fehlgeschlagen.'
|
||
|
||
publish({
|
||
phase: 'error',
|
||
total: Math.max(1, totalFallback),
|
||
current: 0,
|
||
completed: 0,
|
||
segmentProgress: 0,
|
||
overallProgress: 0,
|
||
message,
|
||
})
|
||
|
||
notify.error('Split fehlgeschlagen', `${file} — ${message}`)
|
||
throw err
|
||
}
|
||
})()
|
||
}}
|
||
/>
|
||
|
||
{playerJob ? (
|
||
<Player
|
||
key={[
|
||
String((playerJob as any)?.id ?? ''),
|
||
baseName(playerJob.output || ''),
|
||
].join('::')}
|
||
job={playerJob}
|
||
modelKey={playerModelKey ?? undefined}
|
||
modelsByKey={modelsByKey}
|
||
expanded={playerExpanded}
|
||
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||
onClose={() => {
|
||
setPlayerJob(null)
|
||
setPlayerStartAtSec(null)
|
||
}}
|
||
startAtSec={playerStartAtSec ?? undefined}
|
||
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
||
isFavorite={Boolean(playerModel?.favorite)}
|
||
isLiked={playerModel?.liked === true}
|
||
isWatching={Boolean(playerModel?.watching)}
|
||
onKeep={handleKeepJob}
|
||
onDelete={handleDeleteJob}
|
||
onToggleHot={handleToggleHot}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
onToggleLike={handleToggleLike}
|
||
onStopJob={stopJob}
|
||
onToggleWatch={handleToggleWatch}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|