// frontend\src\App.tsx

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import './App.css'
import Button from './components/ui/Button'
import CookieModal from './components/ui/CookieModal'
import Tabs, { type TabItem } from './components/ui/Tabs'
import RecorderSettings from './components/ui/RecorderSettings'
import FinishedDownloads from './components/ui/FinishedDownloads'
import Player from './components/ui/Player'
import type { RecordJob } from './types'
import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails'
import {
  SignalIcon,
  HeartIcon,
  HandThumbUpIcon,
  EyeIcon,
  ArrowDownTrayIcon,
  ArchiveBoxIcon,
  UserGroupIcon,
  Squares2X2Icon,
  Cog6ToothIcon,
} from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
import VideoSplitModal from './components/ui/VideoSplitModal'
import TextInput from './components/ui/TextInput'
import LastUpdatedText from './components/ui/LastUpdatedText'

const COOKIE_STORAGE_KEY = 'record_cookies'

function normalizeCookies(obj: Record<string, string> | null | undefined): Record<string, string> {
  const input = obj ?? {}
  return Object.fromEntries(
    Object.entries(input)
      .map(([k, v]) => [k.trim().toLowerCase(), String(v ?? '').trim()] as const)
      .filter(([k, v]) => k.length > 0 && v.length > 0)
  )
}

async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init)
  if (!res.ok) {
    const text = await res.text().catch(() => '')
    throw new Error(text || `HTTP ${res.status}`)
  }
  return res.json() as Promise<T>
}

type RecorderSettingsState = {
  recordDir: string
  doneDir: string
  ffmpegPath?: string
  autoAddToDownloadList?: boolean
  autoStartAddedDownloads?: boolean
  enableConcurrentDownloadsLimit?: boolean
  maxConcurrentDownloads?: number
  useChaturbateApi?: boolean
  useMyFreeCamsWatcher?: boolean
  autoDeleteSmallDownloads?: boolean
  autoDeleteSmallDownloadsBelowMB?: number
  blurPreviews?: boolean
  teaserPlayback?: 'still' | 'hover' | 'all'
  teaserAudio?: boolean
  lowDiskPauseBelowGB?: number
  enableNotifications?: boolean
}

type JobEvent = {
  type?: 'job_upsert' | 'job_remove'
  model?: string
  jobId?: string
  status?: string
  phase?: string
  progress?: number
  sourceUrl?: string
  output?: string
  startedAt?: string
  startedAtMs?: number
  endedAt?: string
  endedAtMs?: number
  sizeBytes?: number
  durationSeconds?: number
  previewState?: string
  roomStatus?: string
  isOnline?: boolean
  modelImageUrl?: string
  modelChatRoomUrl?: string
  ts?: number
  error?: string

  postWorkKey?: string
  postWork?: {
    state?: string
    position?: number
    waiting?: number
    running?: number
    maxParallel?: number
  }

  // ✅ neu für finished_postwork
  file?: string
  assetId?: string
  queue?: 'postwork' | 'enrich'
  state?: 'queued' | 'running' | 'done' | 'error' | 'missing'
  label?: string
  position?: number
  waiting?: number
  running?: number
  maxParallel?: number
}

type TaskStateEvent = {
  type?: 'task_state'
  generateAssetsRunning?: boolean
  regenerateAssetsRunning?: boolean
  cleanupRunning?: boolean
  anyRunning?: boolean
  ts?: number
}

const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
  recordDir: 'records',
  doneDir: 'records/done',
  ffmpegPath: '',
  autoAddToDownloadList: false,
  autoStartAddedDownloads: false,
  enableConcurrentDownloadsLimit: false,
  maxConcurrentDownloads: 20,
  useChaturbateApi: false,
  useMyFreeCamsWatcher: false,
  autoDeleteSmallDownloads: false,
  autoDeleteSmallDownloadsBelowMB: 200,
  blurPreviews: false,
  teaserPlayback: 'hover',
  teaserAudio: false,
  lowDiskPauseBelowGB: 5,
  enableNotifications: true,
}

type StoredModel = {
  id: string
  input: string
  host?: string
  modelKey: string
  watching: boolean
  favorite?: boolean
  liked?: boolean | null
  isUrl?: boolean
  path?: string

  roomStatus?: string
  isOnline?: boolean
  chatRoomUrl?: string
  imageUrl?: string
  lastOnlineAt?: string
  lastOfflineAt?: string
  lastRoomSyncAt?: string
}

type PendingWatchedRoom = {
  id: string
  modelKey: string
  url: string
  currentShow: string // private/hidden/away/public/unknown
  imageUrl?: string
}

type ParsedModel = {
  input: string
  isUrl: boolean
  host?: string
  path?: string
  modelKey: string
}

type ChaturbateOnlineRoomItem = {
  username?: string
  current_show?: string
  chat_room_url?: string
  image_url?: string
}

type ChaturbateOnlineResponse = {
  enabled?: boolean
  fetchedAt?: string
  count?: number
  total?: number
  lastError?: string
  rooms?: ChaturbateOnlineRoomItem[]
}

type AutostartState = {
  paused?: boolean
  pausedByUser?: boolean
  pausedByDisk?: boolean
}

type PendingAutoStartMode = 'wait_public' | 'probe_retry'

type PendingAutoStartItem = {
  modelKey: string
  url: string
  mode?: PendingAutoStartMode
  nextProbeAtMs?: number
  currentShow?: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
  imageUrl?: string
}

type PendingAutoStartResponse = {
  items?: PendingAutoStartItem[]
}

function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
  const s = String(v ?? '').trim().toLowerCase()

  switch (s) {
    case 'public':
      return 'public'
    case 'private':
      return 'private'
    case 'hidden':
      return 'hidden'
    case 'away':
      return 'away'
    case 'offline':
      return 'offline'
    default:
      return 'unknown'
  }
}

function normalizeRoomStatusText(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
  return normalizePendingShow(v)
}

function roomStatusLabelDE(v: unknown): string {
  switch (normalizeRoomStatusText(v)) {
    case 'public':
      return 'live'
    case 'private':
      return 'privat'
    case 'hidden':
      return 'hidden'
    case 'away':
      return 'away'
    case 'offline':
      return 'offline'
    default:
      return 'unbekannt'
  }
}

function normalizeHttpUrl(raw: string): string | null {
  let v = (raw ?? '').trim()
  if (!v) return null

  // häufige Copy/Paste-Randzeichen entfernen
  v = v.replace(/^[("'[{<]+/, '').replace(/[)"'\]}>.,;:]+$/, '')

  // ohne Scheme -> https://
  if (!/^https?:\/\//i.test(v)) v = `https://${v}`

  try {
    const u = new URL(v)
    if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
    return u.toString()
  } catch {
    return null
  }
}

function extractFirstUrl(text: string): string | null {
  const t = (text ?? '').trim()
  if (!t) return null

  for (const token of t.split(/\s+/g)) {
    const url = normalizeHttpUrl(token)
    if (url) return url
  }
  return null
}

type Provider = 'chaturbate' | 'mfc'

function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
  try {
    const host = new URL(normUrl).hostname.replace(/^www\./i, '').toLowerCase()
    if (host === 'chaturbate.com' || host.endsWith('.chaturbate.com')) return 'chaturbate'
    if (host === 'myfreecams.com' || host.endsWith('.myfreecams.com')) return 'mfc'
    return null
  } catch {
    return null
  }
}

function chaturbateUserFromUrl(normUrl: string): string {
  try {
    const u = new URL(normUrl)
    const host = u.hostname.replace(/^www\./i, '').toLowerCase()
    if (host !== 'chaturbate.com' && !host.endsWith('.chaturbate.com')) return ''

    // https://chaturbate.com/<name>/...
    const parts = u.pathname.split('/').filter(Boolean)
    return parts[0] ? decodeURIComponent(parts[0]).trim() : ''
  } catch {
    return ''
  }
}

/**
 * Macht aus "beliebigen" Provider-URLs eine EINDEUTIGE Standardform.
 * -> wichtig für dedupe (Queue, alreadyRunning), Clipboard, Pending-Maps.
 */
function canonicalizeProviderUrl(normUrl: string): string {
  const provider = getProviderFromNormalizedUrl(normUrl)
  if (!provider) return normUrl

  if (provider === 'chaturbate') {
    const name = chaturbateUserFromUrl(normUrl)
    return name ? `https://chaturbate.com/${encodeURIComponent(name)}/` : normUrl
  }

  // provider === 'mfc'
  const name = mfcUserFromUrl(normUrl)
  // Standardisiere auf EIN Format (hier: #<name>)
  return name ? `https://www.myfreecams.com/#${encodeURIComponent(name)}` : normUrl
}

/** Gibt den "ModelKey" aus einer URL zurück (lowercased) – für beide Provider */
function providerKeyLowerFromUrl(normUrl: string): string {
  const provider = getProviderFromNormalizedUrl(normUrl)
  if (!provider) return ''
  const raw = provider === 'chaturbate' ? chaturbateUserFromUrl(normUrl) : mfcUserFromUrl(normUrl)
  return (raw || '').trim().toLowerCase()
}


function mfcUserFromUrl(normUrl: string): string {
  try {
    const u = new URL(normUrl)
    const host = u.hostname.replace(/^www\./i, '').toLowerCase()

    // nur MFC
    if (host !== 'myfreecams.com' && !host.endsWith('.myfreecams.com')) return ''

    // typische MFC Profile-URLs:
    // https://www.myfreecams.com/#<name>
    // https://www.myfreecams.com/#/models/<name>
    // https://www.myfreecams.com/<name>  (seltener)
    const hash = (u.hash || '').replace(/^#\/?/, '') // "#/models/foo" -> "models/foo"
    if (hash) {
      const parts = hash.split('/').filter(Boolean)
      const last = parts[parts.length - 1] || ''
      if (last) return decodeURIComponent(last).trim()
    }

    const parts = u.pathname.split('/').filter(Boolean)
    const last = parts[parts.length - 1] || ''
    return last ? decodeURIComponent(last).trim() : ''
  } catch {
    return ''
  }
}

const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''

function videoSrcFromJob(job: RecordJob | null): string {
  if (!job) return ''
  const file = baseName(job.output || '')
  if (!file) return ''
  return `/api/record/video?file=${encodeURIComponent(file)}`
}

function replaceBasename(fullPath: string, newBase: string) {
  const norm = (fullPath || '').replaceAll('\\', '/')
  const parts = norm.split('/')
  parts[parts.length - 1] = newBase
  return parts.join('/')
}

function stripHotPrefix(name: string) {
  return name.startsWith('HOT ') ? name.slice(4) : name
}

// wie backend models.go
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/

function chaturbateModelKeyFromJob(job: RecordJob): string {
  const raw = String((job as any)?.sourceUrl ?? '').trim()
  const norm0 = normalizeHttpUrl(raw)
  const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''

  const keyFromUrl = providerKeyLowerFromUrl(norm)
  if (keyFromUrl) return keyFromUrl

  const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
  return keyFromFile
}

function modelKeyFromFilename(fileOrPath: string): string | null {
  const file = stripHotPrefix(baseName(fileOrPath))
  const base = file.replace(/\.[^.]+$/, '') // ext weg

  const m = base.match(reModel)
  if (m?.[1]?.trim()) return m[1].trim()

  const i = base.lastIndexOf('_')
  if (i > 0) return base.slice(0, i)

  return base ? base : null
}

function modelEventKeyFromJob(job: RecordJob): string {
  const raw = String((job as any)?.sourceUrl ?? '').trim()
  const norm0 = normalizeHttpUrl(raw)
  const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''

  const keyFromUrl = providerKeyLowerFromUrl(norm)
  if (keyFromUrl) return keyFromUrl

  const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()
  return keyFromFile
}

function modelEventKeyFromDoneJob(job: RecordJob): string {
  const { modelKey } = modelKeyAndHostFromJob(job)
  return String(modelKey || '').trim().toLowerCase()
}

function modelKeyAndHostFromJob(job: RecordJob): { modelKey: string; host: string } {
  const keyFromFile = (modelKeyFromFilename((job as any)?.output || '') || '').trim().toLowerCase()

  let host = ''
  try {
    const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
    const u0 = extractFirstUrl(raw)
    if (u0) {
      const u = new URL(u0)
      host = u.hostname.replace(/^www\./i, '').toLowerCase()
    }
  } catch {
    // ignore
  }

  if (keyFromFile) {
    return { modelKey: keyFromFile, host }
  }

  const raw = String((job as any)?.sourceUrl ?? (job as any)?.SourceURL ?? '')
  const u0 = extractFirstUrl(raw)
  if (u0) {
    const norm0 = normalizeHttpUrl(u0)
    const norm = norm0 ? canonicalizeProviderUrl(norm0) : ''
    const keyFromUrl = providerKeyLowerFromUrl(norm)
    if (keyFromUrl) {
      return { modelKey: keyFromUrl, host }
    }
  }

  return { modelKey: '', host }
}

const isTerminalJobStatusForCount = (status?: unknown) => {
  const s = String(status ?? '').trim().toLowerCase()
  return (
    s === 'stopped' ||
    s === 'finished' ||
    s === 'failed' ||
    s === 'done' ||
    s === 'completed' ||
    s === 'canceled' ||
    s === 'cancelled'
  )
}

const isPostworkJobForCount = (job: RecordJob): boolean => {
  const anyJ = job as any
  const phase = String(anyJ.phase ?? '').trim()
  const pw = anyJ.postWork
  const pwKey = String(anyJ.postWorkKey ?? '').trim()

  if (pwKey) return true
  if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
  if (job.endedAt && phase) return true
  if (phase.toLowerCase() === 'postwork') return true

  return false
}

function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] {
  const incomingId = String((incoming as any)?.id ?? '').trim()
  if (!incomingId) return list

  const byId = new Map<string, RecordJob>()

  for (const j of list) {
    const id = String((j as any)?.id ?? '').trim()
    if (!id) continue
    byId.set(id, j)
  }

  byId.set(incomingId, {
    ...(byId.get(incomingId) ?? ({} as RecordJob)),
    ...incoming,
  })

  return Array.from(byId.values()).sort((a, b) => {
    const aMs =
      Number((a as any)?.startedAtMs ?? 0) ||
      (a?.startedAt ? Date.parse(a.startedAt) || 0 : 0)

    const bMs =
      Number((b as any)?.startedAtMs ?? 0) ||
      (b?.startedAt ? Date.parse(b.startedAt) || 0 : 0)

    return bMs - aMs
  })
}

export default function App() {

  const [authChecked, setAuthChecked] = useState(false)
  const [authed, setAuthed] = useState(false)
  const sourceUrlInputRef = useRef<HTMLInputElement | null>(null)
  
  const notify = useNotify()

  const notifyRef = useRef(notify)

  const lastKnownRoomStatusByKeyRef = useRef<Record<string, string>>({})
  const roomStatusBaselineReadyRef = useRef(false)

  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 bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])

  const [recSettings, setRecSettings] = useState<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS)

  const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList)
  const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads)

  const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
  const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})
  const [pendingAutoStartModeByKey, setPendingAutoStartModeByKey] = useState<Record<string, PendingAutoStartMode>>({})
  const [pendingBlindRetryAtByKey, setPendingBlindRetryAtByKey] = useState<Record<string, number>>({})

  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({})

      pendingAutoStartByKeyRef.current = {}
      pendingAutoStartModeByKeyRef.current = {}
      pendingBlindRetryAtByKeyRef.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 = 6
  const [donePageSize, setDonePageSize] = useState<number>(BASE_DONE_PAGE_SIZE)

  type DoneSortMode =
    | 'completed_desc'
    | 'completed_asc'
    | 'file_asc'
    | 'file_desc'
    | 'duration_desc'
    | 'duration_asc'
    | 'size_desc'
    | 'size_asc'

  const DONE_SORT_KEY = 'finishedDownloads_sort'
  const [doneSort, setDoneSort] = useState<DoneSortMode>(() => {
    try {
      const v = window.localStorage.getItem(DONE_SORT_KEY) as DoneSortMode | null
      return v || 'completed_desc'
    } catch {
      return 'completed_desc'
    }
  })

  type DonePrefetch = {
    key: string
    items: RecordJob[]
    ts: number
  }

  const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`

  const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
    const safeCount = Number.isFinite(count) && count > 0 ? count : 0
    return Math.max(1, Math.ceil(safeCount / pageSize))
  }

  const prefetchDonePage = useCallback(async (pageToFetch: number) => {
    if (pageToFetch < 1) return
    if (donePrefetchInFlightRef.current) return

    const key = makePrefetchKey(pageToFetch, doneSort)
    const cur = donePrefetchRef.current
    if (cur?.key === key && Date.now() - cur.ts < 15_000) {
      // frisch genug
      return
    }

    donePrefetchInFlightRef.current = true
    try {
      const res = await fetch(
        `/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
          includeKeep ? '&includeKeep=1' : ''
        }`,
        { cache: 'no-store' as any }
      )
      if (!res.ok) return
      const data = await res.json().catch(() => null)

      const items = Array.isArray(data?.items)
        ? (data.items as RecordJob[])
        : Array.isArray(data)
          ? (data as RecordJob[])
          : []

      donePrefetchRef.current = { key, items, ts: Date.now() }
    } finally {
      donePrefetchInFlightRef.current = false
    }
  }, [doneSort, includeKeep, donePageSize])

  const loadDoneCount = useCallback(async () => {
    const now = Date.now()

    // ✅ harte Dedupe-Schranke gegen Bursts
    if (doneCountInFlightRef.current) return
    if (now - doneCountLastAtRef.current < 500) return

    doneCountInFlightRef.current = true
    doneCountLastAtRef.current = now

    try {
      const res = await fetch(
        `/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
        { cache: 'no-store' as any }
      )
      if (!res.ok) return

      const data = await res.json().catch(() => null)
      const countRaw = Number(data?.count ?? 0)
      const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0

      setDoneCount(count)
    } catch {
      // ignore
    } finally {
      doneCountInFlightRef.current = false
    }
  }, [includeKeep])

  const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
    try {
      const key = makePrefetchKey(pageToLoad, sortToLoad)
      const prefetched = donePrefetchRef.current

      if (prefetched?.key === key && Array.isArray(prefetched.items)) {
        setDoneJobs(prefetched.items)

        donePrefetchRef.current = null

        return
      }

      const res = await fetch(
        `/api/record/done?page=${pageToLoad}&pageSize=${donePageSize}&sort=${encodeURIComponent(sortToLoad)}&withCount=1${
          includeKeep ? '&includeKeep=1' : ''
        }`,
        { cache: 'no-store' as any }
      )
      if (!res.ok) return

      const data = await res.json().catch(() => null)

      const items = Array.isArray(data?.items)
        ? (data.items as RecordJob[])
        : []

      setDoneJobs(items)

      const countRaw = Number(data?.count ?? doneCount)
      const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0

      if (count !== doneCount) {
        setDoneCount(count)
      }

      const totalPages = getDoneTotalPages(count, donePageSize)
      if (pageToLoad < totalPages) {
        void prefetchDonePage(pageToLoad + 1)
      }
    } catch {
      // ignore
    }
  }, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])

  useEffect(() => {
    try {
      window.localStorage.setItem(INCLUDE_KEEP_KEY, includeKeep ? '1' : '0')
    } catch {
      // ignore
    }
  }, [includeKeep])

  const 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> = {}

      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'

        if (!keyLower || !norm0) continue

        const norm = canonicalizeProviderUrl(norm0)

        nextByKey[keyLower] = norm
        nextModeByKey[keyLower] = mode

        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
    } 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,
        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
    if (isTerminalJobStatusForCount((job as any)?.status)) return false
    return true
  }

  const buildModelsByKey = useCallback((list: StoredModel[]) => {
    const map: Record<string, StoredModel> = {}

    for (const m of Array.isArray(list) ? list : []) {
      const k = (m?.modelKey || '').trim().toLowerCase()
      if (!k) continue

      const score = (x: StoredModel) => (x.favorite ? 4 : 0) + (x.liked === true ? 2 : 0) + (x.watching ? 1 : 0)

      const cur = map[k]
      if (!cur || score(m) >= score(cur)) map[k] = m
    }

    return map
  }, [])

  const applyRoomStateToModel = useCallback((
    keyLower: string,
    patch: {
      roomStatus?: string
      isOnline?: boolean
      imageUrl?: string
      chatRoomUrl?: string
    }
  ) => {
    if (!keyLower) return

    setModelsByKey((prev) => {
      const cur = prev[keyLower]
      if (!cur) return prev

      const next: StoredModel = {
        ...cur,
        roomStatus:
          patch.roomStatus != null
            ? String(patch.roomStatus).trim().toLowerCase()
            : cur.roomStatus,
        isOnline:
          patch.isOnline != null
            ? Boolean(patch.isOnline)
            : cur.isOnline,
        imageUrl:
          patch.imageUrl && String(patch.imageUrl).trim()
            ? String(patch.imageUrl).trim()
            : cur.imageUrl,
        chatRoomUrl:
          patch.chatRoomUrl && String(patch.chatRoomUrl).trim()
            ? String(patch.chatRoomUrl).trim()
            : cur.chatRoomUrl,
      }

      return { ...prev, [keyLower]: next }
    })

    setPendingWatchedRooms((prev) => {
      const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
      if (idx < 0) return prev

      const copy = [...prev]
      copy[idx] = {
        ...copy[idx],
        currentShow: normalizePendingShow(patch.roomStatus),
        imageUrl:
          patch.imageUrl && String(patch.imageUrl).trim()
            ? String(patch.imageUrl).trim()
            : copy[idx].imageUrl,
      }
      return copy
    })
  }, [])

  type AppModelsSnapshot = {
    totalCount: number
    onlineModelsCount: number
    onlineWatchedModelsCount: number
    onlineFavCount: number
    onlineLikedCount: number
    models: StoredModel[]
  }

  const [headerStats, setHeaderStats] = useState({
    totalCount: 0,
    onlineModelsCount: 0,
    onlineWatchedModelsCount: 0,
    onlineFavCount: 0,
    onlineLikedCount: 0,
  })

  useEffect(() => {
    return () => {
      if (pendingJobEventsTimerRef.current != null) {
        window.clearTimeout(pendingJobEventsTimerRef.current)
      }
    }
  }, [])

  useEffect(() => {
    setModelsCount(headerStats.totalCount)
  }, [headerStats.totalCount])

  const overviewInFlightRef = useRef(false)
  const overviewLastSigRef = useRef('')
  const overviewLastAtRef = useRef(0)
  const overviewTimerRef = useRef<number | null>(null)

  const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
    const uniqKeys = Array.from(
      new Set(
        keys
          .map((x) => String(x ?? '').trim().toLowerCase())
          .filter(Boolean)
      )
    )

    const sig = uniqKeys.join('|')
    const now = Date.now()

    if (overviewInFlightRef.current) return
    if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return

    overviewInFlightRef.current = true
    overviewLastSigRef.current = sig
    overviewLastAtRef.current = now

    try {
      const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        cache: 'no-store' as any,
        body: JSON.stringify({
          keys: uniqKeys,
          includeWatched: true,
        }),
      })

      setHeaderStats({
        totalCount: Number(data?.totalCount ?? 0),
        onlineModelsCount: Number(data?.onlineModelsCount ?? 0),
        onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0),
        onlineFavCount: Number(data?.onlineFavCount ?? 0),
        onlineLikedCount: Number(data?.onlineLikedCount ?? 0),
      })

      const safeList = Array.isArray(data?.models) ? data.models : []
      setModelsByKey(buildModelsByKey(safeList))
    } catch {
      // ignore
    } finally {
      overviewInFlightRef.current = false
    }
  }, [buildModelsByKey])

  const maybeNotifyWatchedModelStatusChange = useCallback((
    keyLower: string,
    nextStatusRaw: unknown,
    patch?: {
      imageUrl?: string
    }
  ) => {
    if (!keyLower) return
    if (!recSettingsRef.current.enableNotifications) return
    if (!roomStatusBaselineReadyRef.current) return

    const model = modelsByKeyRef.current[keyLower]
    if (!model?.watching) {
      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 },
          })
        )
      },
    })
  }, [])

  const enqueueStart = useCallback(
    (item: StartQueueItem) => {
      const norm0 = normalizeHttpUrl(item.url)
      if (!norm0) return false
      const norm = canonicalizeProviderUrl(norm0)

      // dedupe: gleiche URL nicht 100x in die Queue
      if (startQueuedSetRef.current.has(norm)) return true
      startQueuedSetRef.current.add(norm)

      startQueueRef.current.push({ ...item, url: norm })

      // pump einmal pro Tick schedulen
      if (!pumpStartQueueScheduledRef.current) {
        pumpStartQueueScheduledRef.current = true
        queueMicrotask(() => {
          pumpStartQueueScheduledRef.current = false
          void pumpStartQueue()
        })
      }
      return true
    },
    // pumpStartQueue kommt gleich darunter (useCallback), daher eslint ggf. meckert -> ok, wir definieren pumpStartQueue als function declaration unten
    []
  )
  
  useEffect(() => {
    onModelJobEventRef.current = (ev: MessageEvent) => {
      try {
        const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
        const modelKey = String(msg?.model ?? '').trim().toLowerCase()

        if (msg?.type === 'job_upsert') {
          if (modelKey) {
            const 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(),
              })

              const pendingUrl = String((pendingAutoStartByKeyRef.current || {})[modelKey] ?? '').trim()
              if (roomStatus === 'public' && pendingUrl && !busyRef.current) {
                enqueueStart({
                  url: pendingUrl,
                  silent: true,
                  pendingKeyLower: modelKey,
                })
              }
            }
          }
        }

        queueJobDelta(msg)
      } catch {
        // ignore
      }
    }
  }, [applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange, queueJobDelta])

  useEffect(() => {
    const onOpen = (ev: Event) => {
      const e = ev as CustomEvent<{ modelKey?: string }>
      const raw0 = (e.detail?.modelKey ?? '').trim()
      if (!raw0) return

      // 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen
      //    Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key
      const looksLikeKey =
        !raw0.includes(' ') &&
        !raw0.includes('/') &&
        !raw0.includes('\\')

      if (looksLikeKey) {
        const k = raw0.replace(/^@/, '').trim().toLowerCase()
        if (k) setDetailsModelKey(k)
        return
      }

      // 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren
      const norm0 = normalizeHttpUrl(raw0)
      if (!norm0) {
        // Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist)
        let k = raw0.replace(/^https?:\/\//i, '')
        if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k
        if (k.includes(':')) k = k.split(':').pop() || k
        k = k.trim().toLowerCase()
        if (k) setDetailsModelKey(k)
        return
      }

      const norm = canonicalizeProviderUrl(norm0)
      const keyLower = providerKeyLowerFromUrl(norm)

      if (keyLower) setDetailsModelKey(keyLower)
    }

    window.addEventListener('open-model-details', onOpen as any)
    return () => window.removeEventListener('open-model-details', onOpen as any)
  }, [])

  useEffect(() => {
    const onOpen = (ev: Event) => {
      const e = ev as CustomEvent<{ jobId?: string; output?: string }>
      const jobId = String(e.detail?.jobId ?? '').trim()
      const output = String(e.detail?.output ?? '').trim()

      let hit: RecordJob | null = null

      if (jobId) {
        hit =
          jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
          doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ??
          null
      }

      if (!hit && output) {
        const wanted = baseName(output)
        hit =
          jobs.find((j) => baseName(j.output || '') === wanted) ??
          doneJobs.find((j) => baseName(j.output || '') === wanted) ??
          null
      }

      if (hit) {
        setSplitJob(hit)
        setSplitModalKey((k) => k + 1)
        setSplitModalOpen(true)
      }
    }

    window.addEventListener('open-video-splitter', onOpen as EventListener)
    return () => window.removeEventListener('open-video-splitter', onOpen as EventListener)
  }, [jobs, doneJobs])

  const upsertModelCache = useCallback((m: StoredModel) => {
    const now = Date.now()
    const cur = modelsCacheRef.current
    if (!cur) {
      modelsCacheRef.current = { ts: now, list: [m] }
      return
    }
    cur.ts = now
    const idx = cur.list.findIndex((x) => x.id === m.id)
    if (idx >= 0) cur.list[idx] = m
    else cur.list.unshift(m)
  }, [])

  const recSettingsRef = useRef(recSettings)
  useEffect(() => {
    recSettingsRef.current = recSettings
  }, [recSettings])

  const [autostartState, setAutostartState] = useState<AutostartState>({
    paused: false,
    pausedByUser: false,
    pausedByDisk: false,
  })

  useEffect(() => {
    busyRef.current = busy
  }, [busy])
  useEffect(() => {
    cookiesRef.current = cookies
  }, [cookies])
  useEffect(() => {
    jobsRef.current = jobs
  }, [jobs])

  // pending start falls gerade busy
  const lastClipboardUrlRef = useRef<string>('')

    // --- START QUEUE (parallel) ---
  const START_CONCURRENCY = 4 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start

  type StartQueueItem = {
    url: string
    silent: boolean
    pendingKeyLower?: string
    hidden?: boolean
  }

  const startQueueRef = useRef<StartQueueItem[]>([])
  const startInFlightRef = useRef(0)
  const startQueuedSetRef = useRef<Set<string>>(new Set()) // dedupe: verhindert Duplikate
  const pumpStartQueueScheduledRef = useRef(false)

  const setBusyFromStarts = useCallback(() => {
    const v = startInFlightRef.current > 0
    setBusy(v)
    busyRef.current = v
  }, [])

  async function doStartNow(
    normUrl: string,
    silent: boolean,
    opts?: { hidden?: boolean }
  ): Promise<boolean> {
    normUrl = canonicalizeProviderUrl(normUrl)

    const alreadyRunning = jobsRef.current.some((j) => {
      if (String(j.status || '').toLowerCase() !== 'running') return false
      if ((j as any).endedAt) return false
      const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
      const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
      return jNorm === normUrl
    })
    if (alreadyRunning) return true

    try {
      const currentCookies = cookiesRef.current

      const provider = getProviderFromNormalizedUrl(normUrl)
      if (!provider) {
        if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.')
        return false
      }

      if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
        if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
        return false
      }

      const cookieString = Object.entries(currentCookies)
        .map(([k, v]) => `${k}=${v}`)
        .join('; ')

      const created = await apiJSON<RecordJob>('/api/record', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          url: normUrl,
          cookie: cookieString,
          hidden: Boolean(opts?.hidden),
        }),
      })

      if (created?.id && !opts?.hidden) {
        startedToastByJobIdRef.current[String(created.id)] = true
      }

      if (!(opts?.hidden || Boolean((created as any)?.hidden))) {
        setJobs((prev) => {
          const next = upsertJobById(prev, created)
          jobsRef.current = next
          return next
        })
      }

      return true
    } catch (e: any) {
      const msg = e?.message ?? String(e)

      if (isCookieGateError(msg)) {
        showMissingCookiesMessage({ silent })
        return false
      }

      if (!silent) setError(msg)
      return false
    }
  }

  async function pumpStartQueue(): Promise<void> {
    // so viele wie möglich parallel starten
    while (startInFlightRef.current < START_CONCURRENCY && startQueueRef.current.length > 0) {
      const next = startQueueRef.current.shift()!
      startInFlightRef.current++
      setBusyFromStarts()

      void (async () => {
        try {
          const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden })

          // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
          if (ok && next.pendingKeyLower) {
            removePendingAutoStart(next.pendingKeyLower)
          }
        } finally {
          // dedupe wieder freigeben
          startQueuedSetRef.current.delete(next.url)

          startInFlightRef.current = Math.max(0, startInFlightRef.current - 1)
          setBusyFromStarts()

          // falls noch was da ist: weiterpumpen
          if (startQueueRef.current.length > 0) {
            void pumpStartQueue()
          }
        }
      })()
    }
  }

  const chaturbateStoreKeysLower = useMemo(() => {
    const set = new Set<string>()
    for (const m of Object.values(modelsByKey)) {
      if (!isChaturbateStoreModel(m)) continue
      const k = lower(String(m?.modelKey ?? ''))
      if (k) set.add(k)
    }
    return Array.from(set)
  }, [modelsByKey, isChaturbateStoreModel])
  
  const watchedModelKeysLower = useMemo(() => {
    const set = new Set<string>()

    for (const m of Object.values(modelsByKey)) {
      if (!m?.watching) continue

      const k = lower(String(m?.modelKey ?? ''))
      if (!k) continue

      set.add(k)
    }

    return Array.from(set)
  }, [modelsByKey])

  const requiredModelKeys = useMemo(() => {
    const set = new Set<string>()

    for (const j of jobs) {
      const k = modelEventKeyFromJob(j)
      if (k) set.add(k)
    }

    for (const j of doneJobs) {
      const k = modelEventKeyFromDoneJob(j)
      if (k) set.add(k)
    }

    for (const p of pendingWatchedRooms) {
      const k = String(p?.modelKey ?? '').trim().toLowerCase()
      if (k) set.add(k)
    }

    if (playerJob) {
      const k = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
      if (k) set.add(k)
    }

    if (detailsModelKey) {
      const k = String(detailsModelKey ?? '').trim().toLowerCase()
      if (k) set.add(k)
    }

    return Array.from(set).sort()
  }, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey])

  const requiredModelKeysSig = useMemo(
    () => requiredModelKeys.join('|'),
    [requiredModelKeys]
  )

  

  const lastOverviewSigRef = useRef<string>('')

  useEffect(() => {
    const scheduleOverviewLoad = () => {
      if (overviewTimerRef.current != null) {
        window.clearTimeout(overviewTimerRef.current)
      }

      overviewTimerRef.current = window.setTimeout(() => {
        void loadAppModelsSnapshot(requiredModelKeys)
      }, 250)
    }

    if (lastOverviewSigRef.current !== requiredModelKeysSig) {
      lastOverviewSigRef.current = requiredModelKeysSig
      scheduleOverviewLoad()
    }

    const onChanged = (ev: Event) => {
      const e = ev as CustomEvent<any>
      const detail = e?.detail ?? {}
      const updated = detail?.model

      if (updated && typeof updated === 'object') {
        const k = String(updated.modelKey ?? '').toLowerCase().trim()

        if (k) {
          setModelsByKey((prev) => ({ ...prev, [k]: updated }))
        }

        try {
          upsertModelCache(updated)
        } catch {}

        setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))

        // counts/headerStats trotzdem später sauber nachziehen
        scheduleOverviewLoad()
        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()
        return
      }

      scheduleOverviewLoad()
    }

    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
      }
    }
  }, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])

  const queuePendingAutoStart = useCallback((
    keyLower: string,
    url: string,
    show?: string,
    imageUrl?: string,
    opts?: {
      mode?: PendingAutoStartMode
      nextProbeAtMs?: number
    }
  ) => {
    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 nextProbeAtMs =
      mode === 'probe_retry'
        ? Number(opts?.nextProbeAtMs ?? (Date.now() + 60_000))
        : undefined

    setPendingAutoStartByKey((prev) => {
      const next = { ...(prev || {}), [key]: norm }
      pendingAutoStartByKeyRef.current = next
      return next
    })

    setPendingAutoStartModeByKey((prev) => {
      const next = { ...(prev || {}), [key]: mode }
      pendingAutoStartModeByKeyRef.current = next
      return next
    })

    setPendingBlindRetryAtByKey((prev) => {
      const next = { ...(prev || {}) }

      if (mode === 'probe_retry') {
        next[key] = Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 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: normalizePendingShow(show),
        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]
    })

    const prevUrl = String(pendingAutoStartByKeyRef.current[key] ?? '').trim()
    const prevMode = String(pendingAutoStartModeByKeyRef.current[key] ?? 'wait_public').trim()
    const prevRetryAt = Number(pendingBlindRetryAtByKeyRef.current[key] ?? 0)
    const nextShow = normalizePendingShow(show)
    const nextRetryAt = mode === 'probe_retry'
      ? (Number.isFinite(nextProbeAtMs) ? Number(nextProbeAtMs) : 0)
      : 0

    const sameUrl = prevUrl === norm
    const sameMode = prevMode === mode
    const sameRetryAt =
      mode !== 'probe_retry' || Math.abs(prevRetryAt - nextRetryAt) < 1000

    if (sameUrl && sameMode && 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' ? nextProbeAtMs : undefined,
        currentShow: nextShow,
        imageUrl: imageUrl || undefined,
      }),
    }).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])

    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
    })

    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)

  useEffect(() => {
    donePageRef.current = donePage
  }, [donePage])

  useEffect(() => {
    doneSortRef.current = doneSort
  }, [doneSort])

  const applyPendingRoomSnapshot = useCallback(
    (
      keyLower: string,
      snap: {
        show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
        imageUrl?: string
        chatRoomUrl?: string
      }
    ) => {
      if (!keyLower) return

      setModelsByKey((prev) => {
        const cur = prev[keyLower]
        if (!cur) return prev

        return {
          ...prev,
          [keyLower]: {
            ...cur,
            roomStatus: snap.show,
            isOnline: snap.show !== 'offline',
            imageUrl: snap.imageUrl || cur.imageUrl,
            chatRoomUrl: snap.chatRoomUrl || cur.chatRoomUrl,
          },
        }
      })

      setPendingWatchedRooms((prev) => {
        const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower)
        if (idx < 0) return prev

        const copy = [...prev]
        copy[idx] = {
          ...copy[idx],
          currentShow: snap.show,
          imageUrl: snap.imageUrl || copy[idx].imageUrl,
        }
        return copy
      })
    },
    []
  )

    // ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird)
  const modelsByKeyRef = useRef(modelsByKey)
  useEffect(() => {
    modelsByKeyRef.current = modelsByKey
  }, [modelsByKey])

  useEffect(() => {
    const next: Record<string, string> = {}

    for (const [rawKey, model] of Object.entries(modelsByKey || {})) {
      const key = String(rawKey ?? '').trim().toLowerCase()
      if (!key) continue

      const status = normalizeRoomStatusText((model as any)?.roomStatus)
      next[key] = status
    }

    lastKnownRoomStatusByKeyRef.current = next

    if (!roomStatusBaselineReadyRef.current && Object.keys(next).length > 0) {
      roomStatusBaselineReadyRef.current = true
    }
  }, [modelsByKey])

  const pendingAutoStartByKeyRef = useRef(pendingAutoStartByKey)
  useEffect(() => {
    pendingAutoStartByKeyRef.current = pendingAutoStartByKey
  }, [pendingAutoStartByKey])

  useEffect(() => {
    const keys = Object.keys(pendingAutoStartByKey || {})
    if (keys.length === 0) {
      setPendingWatchedRooms([])
      return
    }

    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 (!recSettings.useChaturbateApi) {
      setCbOnlineFetchedAt('')
      return
    }

    let cancelled = false
    let timer: number | null = null
    let inFlight = false

    const tick = async () => {
      if (cancelled || inFlight) return

      const pendingMap = pendingAutoStartByKeyRef.current || {}
      const entries = Object.entries(pendingMap)

      if (entries.length === 0) return
      if (busyRef.current) return

      const keys = entries
        .map(([rawKey]) => String(rawKey ?? '').trim().toLowerCase())
        .filter(Boolean)

      if (keys.length === 0) return

      inFlight = true
      try {
        const res = await fetch('/api/chaturbate/online', {
          method: 'POST',
          cache: 'no-store',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            q: keys,
          }),
        })

        const byKey: Record<
          string,
          {
            show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown'
            imageUrl?: string
            chatRoomUrl?: string
          }
        > = {}

        if (res.ok) {
          const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null
          const rooms = Array.isArray(data?.rooms) ? data.rooms : []

          for (const room of rooms) {
            const key = String(room?.username ?? '').trim().toLowerCase()
            if (!key) continue

            byKey[key] = {
              show: normalizePendingShow(room?.current_show),
              imageUrl: String(room?.image_url ?? '').trim() || undefined,
              chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined,
            }
          }
        }

        const modeMap = pendingAutoStartModeByKeyRef.current || {}
        const retryAtMap = pendingBlindRetryAtByKeyRef.current || {}

        for (const key of keys) {
          const snap =
            byKey[key] ?? {
              show: 'unknown' as const,
              imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined,
              chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined,
            }

          maybeNotifyWatchedModelStatusChange(key, snap.show, {
            imageUrl: snap.imageUrl,
          })

          applyPendingRoomSnapshot(key, snap)

          const url = String((pendingMap as any)?.[key] ?? '').trim()
          if (!url) continue

          const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode

          if (snap.show === 'public') {
            enqueueStart({
              url,
              silent: true,
              pendingKeyLower: key,
            })
            continue
          }

          if (mode === 'probe_retry') {
            // Sobald API das Model als private/hidden/away kennt:
            // nicht mehr blind probieren, sondern einfach auf public warten.
            if (
              snap.show === 'private' ||
              snap.show === 'hidden' ||
              snap.show === 'away'
            ) {
              queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
                mode: 'wait_public',
              })
              continue
            }

            const nextProbeAtMs = Number(retryAtMap[key] ?? 0)
            if (!busyRef.current && Date.now() >= nextProbeAtMs) {
              enqueueStart({
                url,
                silent: true,
                hidden: true,
              })

              queuePendingAutoStart(key, url, snap.show, snap.imageUrl, {
                mode: 'probe_retry',
                nextProbeAtMs: Date.now() + 60_000,
              })
            }

            continue
          }

          // mode === 'wait_public'
          // private / hidden / away / offline / unknown => einfach warten
        }
      } catch {
        // ignore
      } finally {
        inFlight = false
      }
    }

    const schedule = (ms?: number) => {
      if (cancelled) return

      const retryMap = pendingBlindRetryAtByKeyRef.current || {}
      const hasProbeRetry = Object.keys(retryMap).length > 0

      const nextMs =
        ms ??
        (hasProbeRetry ? 1000 : (document.hidden ? 8000 : 3000))

      timer = window.setTimeout(async () => {
        await tick()
        schedule()
      }, nextMs)
    }

    schedule(0)

    return () => {
      cancelled = true
      if (timer != null) window.clearTimeout(timer)
    }
  }, [
    recSettings.useChaturbateApi,
    applyPendingRoomSnapshot,
    enqueueStart,
    maybeNotifyWatchedModelStatusChange,
    removePendingAutoStart,
  ])

  const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower)

  useEffect(() => {
    chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower
  }, [chaturbateStoreKeysLower])

  useEffect(() => {
    selectedTabRef.current = selectedTab
  }, [selectedTab])

  useEffect(() => {
    if (!recSettings.useChaturbateApi) {
      setRoomStatusByModelKey({})
      return
    }

    const next: Record<string, string> = {}

    for (const j of jobs) {
      const key = chaturbateModelKeyFromJob(j)
      if (!key) continue

      const direct = String((j as any)?.roomStatus ?? '').trim().toLowerCase()
      if (direct) {
        next[key] = direct
        continue
      }

      const fromModel = String((modelsByKey[key] as any)?.roomStatus ?? '').trim().toLowerCase()
      if (fromModel) {
        next[key] = fromModel
      }
    }

    setRoomStatusByModelKey(next)
  }, [jobs, modelsByKey, recSettings.useChaturbateApi])

  useEffect(() => {
    if (!authed || !recSettings.useChaturbateApi) {
      setCbOnlineFetchedAt('')
      return
    }

    let cancelled = false
    let timer: number | null = null

    const tick = async () => {
      try {
        const onlineRes = await fetch('/api/chaturbate/online', {
          method: 'GET',
          cache: 'no-store',
        })

        if (!onlineRes.ok) return

        const data = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
        if (cancelled || !data) return

        const fetchedAt = String(data.fetchedAt ?? '').trim()
        if (fetchedAt && fetchedAt !== '0001-01-01T00:00:00Z') {
          setCbOnlineFetchedAt(fetchedAt)
        }
      } catch {
        // ignore
      }
    }

    void tick()

    timer = window.setInterval(() => {
      void tick()
    }, 10_000)

    return () => {
      cancelled = true
      if (timer != null) window.clearInterval(timer)
    }
  }, [authed, recSettings.useChaturbateApi])

  // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt)
  const startUrl = useCallback(async (rawUrl: string, opts?: { silent?: boolean }): Promise<boolean> => {
    const norm0 = normalizeHttpUrl(rawUrl)
    if (!norm0) return false
    const norm = canonicalizeProviderUrl(norm0)

    const silent = Boolean(opts?.silent)
    if (!silent) setError(null)

    const provider = getProviderFromNormalizedUrl(norm)
    if (!provider) {
      if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.')
      return false
    }

    const currentCookies = cookiesRef.current

    if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) {
      if (!silent) setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.')
      return false
    }

    // Duplicate-running guard (normalisiert vergleichen)
    const alreadyRunning = jobsRef.current.some((j) => {
      if (String(j.status || '').toLowerCase() !== 'running') return false

      // ✅ Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
      if ((j as any).endedAt) return false

      const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || ''))
      const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : ''
      return jNorm === norm
    })
    if (alreadyRunning) return true

    // ✅ Chaturbate-Startlogik mit Online-API:
    // public -> direkt starten
    // private/hidden/away -> wait_public
    // offline/unknown -> probe_retry mit hidden start
    if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
      try {
        const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ input: norm }),
        })

        const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()

        if (mkLower) {
          let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown'
          let resolvedImageUrl: string | undefined
          let resolvedChatRoomUrl: string | undefined
          let foundInApi = false

          try {
            const onlineRes = await fetch('/api/chaturbate/online', {
              method: 'POST',
              cache: 'no-store',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ q: [mkLower] }),
            })

            if (onlineRes.ok) {
              const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null
              const room = Array.isArray(onlineData?.rooms)
                ? onlineData.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower)
                : null

              if (room) {
                foundInApi = true
                resolvedShow = normalizePendingShow(room.current_show)
                resolvedImageUrl = String(room.image_url ?? '').trim() || undefined
                resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined
              }
            }
          } catch {
            // best effort
          }

          applyPendingRoomSnapshot(mkLower, {
            show: resolvedShow,
            imageUrl: resolvedImageUrl,
            chatRoomUrl: resolvedChatRoomUrl,
          })

          // 1) in API + public => sofort normal starten
          if (foundInApi && resolvedShow === 'public') {
            removePendingAutoStart(mkLower)
            // unten normal weiter
          } else if (
            foundInApi &&
            (resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away')
          ) {
            // 2) in API + nicht public => warten bis public
            queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
              mode: 'wait_public',
            })
            return true
          } else {
            // 3) nicht in API ODER offline/unknown => Blind-Probe jetzt starten
            queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, {
              mode: 'probe_retry',
              nextProbeAtMs: Date.now() + 60_000,
            })

            const ok = await doStartNow(norm, silent, { hidden: true })
            if (!ok) {
              removePendingAutoStart(mkLower)
              return false
            }
            return true
          }
        }
      } catch {
        const mkLower = providerKeyLowerFromUrl(norm)
        if (mkLower) {
          queuePendingAutoStart(mkLower, norm, 'unknown', undefined, {
            mode: 'probe_retry',
            nextProbeAtMs: Date.now() + 60_000,
          })

          const ok = await doStartNow(norm, silent, { hidden: true })
          if (!ok) {
            removePendingAutoStart(mkLower)
            return false
          }
          return true
        }
      }
    }

    try {
      const cookieString = Object.entries(currentCookies)
        .map(([k, v]) => `${k}=${v}`)
        .join('; ')

      const created = await apiJSON<RecordJob>('/api/record', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url: norm, cookie: cookieString }),
      })

      // ✅ verhindert Doppel-Toast: StartUrl toastet ggf. schon selbst,
      // und kurz danach kommt der Job nochmal über SSE/polling rein.
      if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true

      setJobs((prev) => {
        const next = upsertJobById(prev, created)
        jobsRef.current = next
        return next
      })
      return true
    } catch (e: any) {
      const msg = e?.message ?? String(e)

      // ✅ Spezialfall: Age-Gate / Cloudflare / kein Room-HTML => Cookies Hinweis
      if (isCookieGateError(msg)) {
        showMissingCookiesMessage({ silent })
        return false
      }

      if (!silent) setError(msg)
      return false
    }
  }, [])

  // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
  useEffect(() => {
    let cancelled = false

    const load = async () => {
      try {
        const s = await apiJSON<RecorderSettingsState>('/api/settings', { cache: 'no-store' })
        if (!cancelled && s) setRecSettings({ ...DEFAULT_RECORDER_SETTINGS, ...s })
      } catch {
        // ignore
      }
    }

    const onUpdated = () => void load()

    let wasHidden = document.hidden
    const onVisible = () => {
      const becameVisible = wasHidden && !document.hidden
      wasHidden = document.hidden
      if (becameVisible) void load()
    }

    window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
    document.addEventListener('visibilitychange', onVisible)

    void load()

    return () => {
      cancelled = true
      window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
      document.removeEventListener('visibilitychange', onVisible)
    }
  }, [])

  const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies])

  const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => {
    modelsCacheRef.current = null
    setPlayerModel(null)
    setPlayerJob(job)
    setPlayerExpanded(false)
    setPlayerStartAtSec(
      typeof startAtSec === 'number' && Number.isFinite(startAtSec) && startAtSec >= 0
        ? startAtSec
        : null
    )
  }, [])

  const openSplitModal = useCallback((job: RecordJob) => {
    setSplitJob(job)
    setSplitModalKey((k) => k + 1)
    setSplitModalOpen(true)
  }, [])

  // ✅ Anzahl Watched Models (aus Store), die online sind
  const onlineModelsCount = headerStats.onlineModelsCount
  const onlineWatchedModelsCount = headerStats.onlineWatchedModelsCount
  const onlineFavCount = headerStats.onlineFavCount
  const onlineLikedCount = headerStats.onlineLikedCount

  const runningTabCount = useMemo(() => {
    const activeDownloads = jobs.filter((j) => {
      if (isPostworkJobForCount(j)) return false
      if (isTerminalJobStatusForCount((j as any)?.status)) return false
      if (Boolean((j as any)?.endedAt)) return false
      return true
    }).length

    const activePostwork = jobs.filter((j) => {
      if (!isPostworkJobForCount(j)) return false
      if (isTerminalJobStatusForCount((j as any)?.status)) return false
      return true
    }).length

    const pendingCount = pendingWatchedRooms.length

    return activeDownloads + activePostwork + pendingCount
  }, [jobs, pendingWatchedRooms])

  const tabs: TabItem[] = [
    {
      id: 'running',
      label: 'Laufende Downloads',
      count: runningTabCount,
      icon: ArrowDownTrayIcon,
    },
    {
      id: 'finished',
      label: 'Abgeschlossene Downloads',
      count: doneCount,
      icon: ArchiveBoxIcon,
    },
    {
      id: 'models',
      label: 'Models',
      count: modelsCount,
      icon: UserGroupIcon,
    },
    {
      id: 'categories',
      label: 'Kategorien',
      icon: Squares2X2Icon,
    },
    {
      id: 'settings',
      label: 'Einstellungen',
      icon: Cog6ToothIcon,
      spinIcon: settingsTaskRunning,
    },
  ]

  const canStart = useMemo(() => sourceUrl.trim().length > 0, [sourceUrl])

  // Cookies load/save
  useEffect(() => {
    let cancelled = false

    const load = async () => {
      try {
        const res = await apiJSON<{ cookies?: Record<string, string> }>('/api/cookies', { cache: 'no-store' })
        const fromBackend = normalizeCookies(res?.cookies)
        if (!cancelled) setCookies(fromBackend)

        if (Object.keys(fromBackend).length === 0) {
          const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
          if (raw) {
            try {
              const obj = JSON.parse(raw) as Record<string, string>
              const local = normalizeCookies(obj)
              if (Object.keys(local).length > 0) {
                if (!cancelled) setCookies(local)
                await apiJSON('/api/cookies', {
                  method: 'POST',
                  headers: { 'Content-Type': 'application/json' },
                  body: JSON.stringify({ cookies: local }),
                })
              }
            } catch {
              // ignore
            }
          }
        }
      } catch {
        const raw = localStorage.getItem(COOKIE_STORAGE_KEY)
        if (raw) {
          try {
            const obj = JSON.parse(raw) as Record<string, string>
            if (!cancelled) setCookies(normalizeCookies(obj))
          } catch {}
        }
      } finally {
        if (!cancelled) setCookiesLoaded(true)
      }
    }

    load()
    return () => {
      cancelled = true
    }
  }, [])

  useEffect(() => {
    if (!cookiesLoaded) return
    localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
  }, [cookies, cookiesLoaded])

  useEffect(() => {
    if (!authed) return

    let es: EventSource | null = null
    let fallbackTimer: number | null = null

    const stopFallbackPoll = () => {
      if (fallbackTimer != null) {
        window.clearInterval(fallbackTimer)
        fallbackTimer = null
      }
    }

    const startFallbackPoll = () => {
      if (fallbackTimer != null) return

      fallbackTimer = window.setInterval(() => {
        if (document.hidden) return

        void loadJobs()
        void loadTaskStateOnce()

        if (selectedTabRef.current === 'finished') {
          void loadDonePage(donePageRef.current, doneSortRef.current)
        }
      }, document.hidden ? 60000 : 5000)
    }

    const loadTaskStateOnce = async () => {
      try {
        const res = await fetch('/api/tasks/status', { cache: 'no-store' as any })
        if (!res.ok) return

        const data = await res.json().catch(() => null)

        setSettingsTaskRunning(
          Boolean(
            data?.anyRunning ||
            data?.generateAssets?.running ||
            data?.regenerateAssets?.running ||
            data?.cleanup?.running
          )
        )
      } catch {
        // ignore
      }
    }

    const applyAutostartState = (data: unknown) => {
      const d = (data ?? {}) as any
      setAutostartState({
        paused: Boolean(d?.paused),
        pausedByUser: Boolean(d?.pausedByUser),
        pausedByDisk: Boolean(d?.pausedByDisk),
      })
    }

    const onDoneChanged = () => {
      if (selectedTabRef.current !== 'finished') return
      void loadDonePage(donePageRef.current, doneSortRef.current)
    }

    const onAutostart = (ev: MessageEvent) => {
      try {
        applyAutostartState(JSON.parse(String(ev.data ?? 'null')))
      } catch {}
    }

    const onFinishedPostwork = (ev: MessageEvent) => {
      try {
        const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent
        const file = String(msg?.file ?? '').trim()
        if (!file) return

        const state =
          msg?.state === 'queued' ||
          msg?.state === 'running' ||
          msg?.state === 'done' ||
          msg?.state === 'error' ||
          msg?.state === 'missing'
            ? msg.state
            : 'missing'

        window.dispatchEvent(
          new CustomEvent('finished-downloads:postwork', {
            detail: {
              file,
              assetId: String(msg?.assetId ?? '').trim() || undefined,
              queue: msg?.queue === 'enrich' ? 'enrich' : 'postwork',
              state,
              phase: String(msg?.phase ?? '').trim() || undefined,
              label: String(msg?.label ?? '').trim() || undefined,
              error: String(msg?.error ?? '').trim() || undefined,
              position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined,
              waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined,
              running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined,
              maxParallel: typeof msg?.maxParallel === 'number' && Number.isFinite(msg.maxParallel) ? msg.maxParallel : undefined,
              ts: typeof msg?.ts === 'number' && Number.isFinite(msg.ts) ? msg.ts : Date.now(),
            },
          })
        )

        // ✅ Wenn Assets fertig / fehlerhaft / entfernt sind, Preview-/Sprite-Cache neu ziehen
        if (state === 'done' || state === 'error' || state === 'missing') {
          bumpAssets()
        }
      } catch {}
    }

    const onTaskState = (ev: MessageEvent) => {
      try {
        const msg = JSON.parse(String(ev.data ?? 'null')) as TaskStateEvent
        setSettingsTaskRunning(
          Boolean(
            msg?.anyRunning ||
            msg?.generateAssetsRunning ||
            msg?.regenerateAssetsRunning ||
            msg?.cleanupRunning
          )
        )
      } catch {}
    }

    void loadJobs()
    void loadDoneCount()
    void loadTaskStateOnce()

    void apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
      .then((s) => {
        setAutostartState({
          paused: Boolean(s?.paused),
          pausedByUser: Boolean(s?.pausedByUser),
          pausedByDisk: Boolean(s?.pausedByDisk),
        })
      })
      .catch(() => {})

    es = new EventSource('/api/events/stream')
    eventSourceRef.current = es
    modelEventNamesRef.current = new Set()

    es.onopen = () => {
      stopFallbackPoll()
      void loadTaskStateOnce()
    }

    es.onerror = () => {
      startFallbackPoll()
    }

    es.addEventListener('doneChanged', onDoneChanged as any)
    es.addEventListener('autostart', onAutostart as any)
    es.addEventListener('finishedPostwork', onFinishedPostwork as any)
    es.addEventListener('taskState', onTaskState as any)

    const onVis = () => {
      if (document.hidden) return
      void loadJobs()
      void loadTaskStateOnce()

      if (selectedTabRef.current === 'finished') {
        void loadDonePage(donePageRef.current, doneSortRef.current)
      }
    }

    document.addEventListener('visibilitychange', onVis)

    return () => {
      document.removeEventListener('visibilitychange', onVis)
      stopFallbackPoll()

      if (es) {
        es.removeEventListener('doneChanged', onDoneChanged as any)
        es.removeEventListener('autostart', onAutostart as any)
        es.removeEventListener('finishedPostwork', onFinishedPostwork as any)
        es.removeEventListener('taskState', onTaskState as any)

        const handler = onModelJobEventRef.current
        if (handler) {
          for (const name of modelEventNamesRef.current) {
            es.removeEventListener(name, handler as any)
          }
        }

        es.close()
      }

      eventSourceRef.current = null
      modelEventNamesRef.current = new Set()
    }
  }, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts])

  useEffect(() => {
    const desired = new Set<string>()

    // 1) Sichtbare Downloads
    for (const j of jobs) {
      if (!isVisibleDownloadJobForSSE(j)) continue

      const key = modelEventKeyFromJob(j)
      if (key) desired.add(key)
    }

    // 2) Sichtbare Nacharbeiten
    for (const j of jobs) {
      if (!isVisiblePostworkJobForSSE(j)) continue

      const key = modelEventKeyFromJob(j)
      if (key) desired.add(key)
    }

    // 3) Sichtbare Finished-Jobs der aktuellen Seite
    for (const j of doneJobs) {
      const key = modelEventKeyFromDoneJob(j)
      if (key) desired.add(key)
    }

    // 4) Sichtbare Wartenden-Einträge
    for (const p of pendingWatchedRooms) {
      const key = String(p?.modelKey ?? '').trim().toLowerCase()
      if (key) desired.add(key)
    }

    // 5) watched Models
    for (const key of watchedModelKeysLower) {
      if (key) desired.add(key)
    }

    // 6) fehlende Listener hinzufügen
    for (const key of desired) {
      ensureModelEventListener(key)
    }

    // 7) alles entfernen, was nicht mehr sichtbar/relevant ist
    for (const key of Array.from(modelEventNamesRef.current)) {
      if (!desired.has(key)) {
        removeModelEventListener(key)
      }
    }
  }, [jobs, doneJobs, pendingWatchedRooms, watchedModelKeysLower, ensureModelEventListener, removeModelEventListener])

  function isChaturbate(raw: string): boolean {
    const norm = normalizeHttpUrl(raw)
    if (!norm) return false
    try {
      return new URL(norm).hostname.includes('chaturbate.com')
    } catch {
      return false
    }
  }

  function getCookie(cookiesObj: Record<string, string>, names: string[]): string | undefined {
    const lowerMap = Object.fromEntries(Object.entries(cookiesObj).map(([k, v]) => [k.trim().toLowerCase(), v]))
    for (const n of names) {
      const v = lowerMap[n.toLowerCase()]
      if (v) return v
    }
    return undefined
  }

  function hasRequiredChaturbateCookies(cookiesObj: Record<string, string>): boolean {
    const cf = getCookie(cookiesObj, ['cf_clearance'])
    const sess = getCookie(cookiesObj, ['sessionid', 'session_id', 'sessionId'])
    return Boolean(cf && sess)
  }

  async function stopJob(id: string) {
    try {
      await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' })
    } catch (e: any) {
      notify.error('Stop fehlgeschlagen', e?.message ?? String(e))
    }
  }

  async function stopAllJobs() {
    try {
      await apiJSON('/api/record/stop-all', { method: 'POST' })
    } catch (e: any) {
      notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e))
    }
  }

  async function removeQueuedPostworkJob(id: string) {
    try {
      await apiJSON(`/api/record/postwork/remove?id=${encodeURIComponent(id)}`, {
        method: 'POST',
      })
    } catch (e: any) {
      notify.error('Entfernen fehlgeschlagen', e?.message ?? String(e))
    }
  }

  useEffect(() => {
    const onHint = (ev: Event) => {
      const e = ev as CustomEvent<{ delta?: number }>
      const delta = Number(e.detail?.delta ?? 0)

      if (Number.isFinite(delta) && delta !== 0) {
        setDoneCount((c) => Math.max(0, c + delta))
      }
    }

    window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
    return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
  }, [])

  useEffect(() => {
    const onNav = (ev: Event) => {
      const d = (ev as CustomEvent<any>).detail || {}
      if (d.tab === 'finished') setSelectedTab('finished')
      if (d.tab === 'categories') setSelectedTab('categories')
      if (d.tab === 'models') setSelectedTab('models')
      if (d.tab === 'running') setSelectedTab('running')
      if (d.tab === 'settings') setSelectedTab('settings')
    }

    window.addEventListener('app:navigate-tab', onNav as EventListener)
    return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
  }, [])

  // ---- Player model sync (wie bei dir) ----
  useEffect(() => {
    if (!playerJob) {
      setPlayerModel(null)
      setPlayerModelKey(null)
      return
    }

    const keyFromFile = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase()
    setPlayerModelKey(keyFromFile || null)

    const hit = keyFromFile ? modelsByKey[keyFromFile] : undefined
    setPlayerModel(hit ?? null)
  }, [playerJob, modelsByKey])

  async function onStart() {
    const ok = await startUrl(sourceUrl, { silent: false })

    if (ok) {
      setSourceUrl('')
    }

    return ok
  }

  const handleAddToDownloads = useCallback(
    async (job: RecordJob): Promise<boolean> => {
      const raw = String(
        (job as any)?.sourceUrl ??
        (job as any)?.SourceURL ??
        (job as any)?.sourceURL ??
        (job as any)?.url ??
        ''
      ).trim()

      const url0 = extractFirstUrl(raw)
      if (!url0) {
        notify.error('Konnte URL nicht hinzufügen', 'Für diesen Eintrag ist keine Source-URL vorhanden.')
        return false
      }

      const norm0 = normalizeHttpUrl(url0)
      if (!norm0) {
        notify.error('Konnte URL nicht hinzufügen', 'Die Source-URL ist ungültig.')
        return false
      }

      const url = canonicalizeProviderUrl(norm0)
      const ok = await startUrl(url, { silent: true })

      if (!ok) {
        notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
      }

      return ok
    },
    [startUrl, notify]
  )

  const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => {
    const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase()

    if (keyLower) {
      removePendingAutoStart(keyLower)
      return
    }

    const url = String(pending?.url ?? '').trim()
    if (!url) return

    setPendingWatchedRooms((prev) =>
      prev.filter((x) => String(x?.url ?? '').trim() !== url)
    )
  }, [removePendingAutoStart])

  type FinishedFileActionKind = 'delete' | 'keep' | 'rename'

  async function runFinishedFileAction<T>(opts: {
    kind: FinishedFileActionKind
    file: string
    run: () => Promise<T>
    onSuccess?: (result: T) => void | Promise<void>
    onError?: (err: unknown) => void | Promise<void>
  }) {
    const { kind, file, run, onSuccess, onError } = opts

    // Einheitliche Start-Event-Phase (delete/keep animieren dieselbe Row)
    if (kind === 'delete' || kind === 'keep') {
      window.dispatchEvent(
        new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
      )
    }

    try {
      const result = await run()

      if (kind === 'delete' || kind === 'keep') {
        window.dispatchEvent(
          new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } })
        )
      }

      await onSuccess?.(result)
      return result
    } catch (e) {
      if (kind === 'delete' || kind === 'keep') {
        window.dispatchEvent(
          new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
        )
      }

      await onError?.(e)
      throw e
    }
  }

  const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
    const removedFile = String(opts?.removedFile ?? '').trim()

    setDoneJobs((prev) => {
      const filtered = removedFile
        ? prev.filter((j) => baseName(j.output || '') !== removedFile)
        : [...prev]

      const need = Math.max(0, donePageSize - filtered.length)
      if (need <= 0) return filtered

      const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
      const buf = donePrefetchRef.current

      if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
        return filtered
      }

      const next: RecordJob[] = [...filtered]
      const used = new Set(
        next.map((x) => String(x.id || baseName(x.output || '')).trim())
      )

      while (next.length < donePageSize && buf.items.length > 0) {
        const cand = buf.items.shift()!
        const id = String(cand.id || baseName(cand.output || '')).trim()
        if (!id || used.has(id)) continue
        used.add(id)
        next.push(cand)
      }

      donePrefetchRef.current = {
        ...buf,
        items: buf.items,
        ts: buf.ts,
      }

      return next
    })
  }, [donePage, donePageSize, doneSort])

  const handleDeleteJobWithUndo = useCallback(
    async (job: RecordJob): Promise<void | { undoToken?: string }> => {
      const file = baseName(job.output || '')
      if (!file) return

      try {
        const data = await runFinishedFileAction<{ undoToken?: string }>({
          kind: 'delete',
          file,
          run: () =>
            apiJSON<{ undoToken?: string }>(
              `/api/record/delete?file=${encodeURIComponent(file)}`,
              { method: 'POST' }
            ),
          onSuccess: async () => {
            window.setTimeout(() => {
              fillDonePageFromPrefetch({ removedFile: file })

              setDoneCount((c) => Math.max(0, c - 1))
              setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
              setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))

              const nextDoneCount = Math.max(0, doneCount - 1)
              const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)

              if (donePage < totalPagesAfterDelete) {
                void prefetchDonePage(donePage + 1)
              }

              void loadDoneCount()
            }, 320)
          },
          onError: async () => {
            notify.error('Löschen fehlgeschlagen', file)
          },
        })

        const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
        return undoToken ? { undoToken } : {}
      } catch {
        return
      }
    },
    [
      runFinishedFileAction,
      notify,
      fillDonePageFromPrefetch,
      donePage,
      prefetchDonePage,
      doneCount,
      loadDoneCount,
    ]
  )

  const handleKeepJob = useCallback(
    async (job: RecordJob): Promise<void> => {
      const file = baseName(job.output || '')
      if (!file) return

      try {
        await runFinishedFileAction({
          kind: 'keep',
          file,
          run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
          onSuccess: async () => {
            window.setTimeout(() => {
              fillDonePageFromPrefetch({ removedFile: file })

              setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
              setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
              setDoneCount((c) => Math.max(0, c - 1))

              const nextDoneCount = Math.max(0, doneCount - 1)
              const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount, donePageSize)

              if (donePage < totalPagesAfterKeep) {
                void prefetchDonePage(donePage + 1)
              }

              void loadDoneCount()
            }, 320)
          },
          onError: async () => {
            notify.error('Keep fehlgeschlagen', file)
          },
        })
      } catch {
        return
      }
    },
    [
      runFinishedFileAction,
      notify,
      fillDonePageFromPrefetch,
      donePage,
      prefetchDonePage,
      loadDoneCount,
      doneCount,
    ]
  )

  const handleToggleHot = useCallback(
    async (job: RecordJob): Promise<void | { ok: boolean; oldFile: string; newFile: string }> => {
      const file = baseName(job.output || '')
      if (!file) return

      try {
        // ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
        window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
        await new Promise((r) => window.setTimeout(r, 60))

        const res = await apiJSON<{ ok: boolean; oldFile: string; newFile: string }>(
          `/api/record/toggle-hot?file=${encodeURIComponent(file)}`,
          { method: 'POST' }
        )

        const oldFile = baseName(res.oldFile || file) || file
        const newFile = baseName(res.newFile || '') || ''
        if (!newFile) throw new Error('Backend lieferte keinen neuen Dateinamen zurück')

        // ✅ FinishedDownloads lokal synchronisieren (wichtig bei Rename aus Player/Details)
        window.dispatchEvent(
          new CustomEvent('finished-downloads:rename', {
            detail: { oldFile, newFile },
          })
        )

        const apply = (out: string) => replaceBasename(out || '', newFile)

        // ✅ Player nur updaten, wenn wirklich derselbe Job / dieselbe Datei
        setPlayerJob((prev) => {
          if (!prev) return prev
          const match = prev.id === job.id || baseName(prev.output || '') === oldFile
          return match ? { ...prev, output: apply(prev.output || '') } : prev
        })

        // ✅ doneJobs über ID (Fallback basename)
        setDoneJobs((prev) =>
          prev.map((j) => {
            const match = j.id === job.id || baseName(j.output || '') === oldFile
            return match ? { ...j, output: apply(j.output || '') } : j
          })
        )

        // ✅ jobs (/record/list) über ID (Fallback basename)
        setJobs((prev) =>
          prev.map((j) => {
            const match = j.id === job.id || baseName(j.output || '') === oldFile
            return match ? { ...j, output: apply(j.output || '') } : j
          })
        )

        return res
      } catch (e: any) {
        notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
        return
      }
    },
    [notify]
  )

  const handleDeleteJob = useCallback(
    async (job: RecordJob): Promise<void> => {
      await handleDeleteJobWithUndo(job)
    },
    [handleDeleteJobWithUndo]
  )

  // --- flags patch (wie bei dir) ---
  async function patchModelFlags(patch: any): Promise<any | null> {
    const res = await fetch('/api/models/flags', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(patch),
    })

    if (res.status === 204) return null
    if (!res.ok) {
      const text = await res.text().catch(() => '')
      throw new Error(text || `HTTP ${res.status}`)
    }
    return res.json()
  }

  const removeModelCache = useCallback((id: string) => {
    const cur = modelsCacheRef.current
    if (!cur || !id) return
    cur.ts = Date.now()
    cur.list = cur.list.filter((x) => x.id !== id)
  }, [])

  // ✅ verhindert Doppel-Requests pro Model
  const flagsInFlightRef = useRef<Record<string, true>>({})

  // ✅ handleToggleFavorite (komplett)
  const handleToggleFavorite = useCallback(
    async (job: RecordJob) => {
      const file = baseName(job.output || '')
      const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)

      const { modelKey, host } = modelKeyAndHostFromJob(job)
      if (!modelKey) {
        notify.error('Favorit umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
        return
      }

      const guardKey = modelKey
      if (flagsInFlightRef.current[guardKey]) return
      flagsInFlightRef.current[guardKey] = true

      const prev =
        modelsByKey[modelKey] ??
        ({
          id: '',
          input: '',
          host: host || undefined,
          modelKey,
          watching: false,
          favorite: false,
          liked: null,
          isUrl: false,
        } as StoredModel)

      const nextFav = !Boolean(prev.favorite)

      const optimistic: StoredModel = {
        ...prev,
        host: prev.host || host || undefined,
        modelKey,
        favorite: nextFav,
        liked: nextFav ? false : prev.liked,
      }

      setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

      try {
        const updated = await patchModelFlags({
          ...(prev.id ? { id: prev.id } : {}),
          host: optimistic.host || '',
          modelKey,
          favorite: nextFav,
          ...(nextFav ? { liked: false } : {}),
        })

        if (!updated) {
          setModelsByKey((p) => {
            const { [modelKey]: _drop, ...rest } = p
            return rest
          })
          if (prev.id) removeModelCache(prev.id)
          if (sameAsPlayer) setPlayerModel(null)
          window.dispatchEvent(
            new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
          )
          return
        }

        const k = lower(updated.modelKey || modelKey)
        if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
        upsertModelCache(updated)
        if (sameAsPlayer) setPlayerModel(updated)
        window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
      } catch (e: any) {
        setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
        upsertModelCache(prev)
        if (sameAsPlayer) setPlayerModel(prev)
        notify.error('Favorit umschalten fehlgeschlagen', e?.message ?? String(e))
      } finally {
        delete flagsInFlightRef.current[guardKey]
      }
    },
    [notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
  )


  // ✅ handleToggleLike (komplett)
  const handleToggleLike = useCallback(
    async (job: RecordJob) => {
      const file = baseName(job.output || '')
      const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)

      const { modelKey, host } = modelKeyAndHostFromJob(job)
      if (!modelKey) {
        notify.error('Like umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
        return
      }

      const guardKey = modelKey
      if (flagsInFlightRef.current[guardKey]) return
      flagsInFlightRef.current[guardKey] = true

      const prev =
        modelsByKey[modelKey] ??
        ({
          id: '',
          input: '',
          host: host || undefined,
          modelKey,
          watching: false,
          favorite: false,
          liked: null,
          isUrl: false,
        } as StoredModel)

      const nextLiked = !(prev.liked === true)

      const optimistic: StoredModel = {
        ...prev,
        host: prev.host || host || undefined,
        modelKey,
        liked: nextLiked,
        favorite: nextLiked ? false : prev.favorite,
      }

      setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

      try {
        const updated = await patchModelFlags({
          ...(prev.id ? { id: prev.id } : {}),
          host: optimistic.host || '',
          modelKey,
          liked: nextLiked,
          ...(nextLiked ? { favorite: false } : {}),
        })

        if (!updated) {
          setModelsByKey((p) => {
            const { [modelKey]: _drop, ...rest } = p
            return rest
          })
          if (prev.id) removeModelCache(prev.id)
          if (sameAsPlayer) setPlayerModel(null)
          window.dispatchEvent(
            new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
          )
          return
        }

        const k = lower(updated.modelKey || modelKey)
        if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
        upsertModelCache(updated)
        if (sameAsPlayer) setPlayerModel(updated)
        window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
      } catch (e: any) {
        setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
        upsertModelCache(prev)
        if (sameAsPlayer) setPlayerModel(prev)
        notify.error('Like umschalten fehlgeschlagen', e?.message ?? String(e))
      } finally {
        delete flagsInFlightRef.current[guardKey]
      }
    },
    [notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
  )


  // ✅ handleToggleWatch (komplett)
  const handleToggleWatch = useCallback(
    async (job: RecordJob) => {
      const file = baseName(job.output || '')
      const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file)

      const { modelKey, host } = modelKeyAndHostFromJob(job)
      if (!modelKey) {
        notify.error('Watched umschalten fehlgeschlagen', 'Kein modelKey ableitbar')
        return
      }

      const guardKey = modelKey
      if (flagsInFlightRef.current[guardKey]) return
      flagsInFlightRef.current[guardKey] = true

      const prev =
        modelsByKey[modelKey] ??
        ({
          id: '',
          input: '',
          host: host || undefined,
          modelKey,
          watching: false,
          favorite: false,
          liked: null,
          isUrl: false,
        } as StoredModel)

      const nextWatching = !Boolean(prev.watching)

      const optimistic: StoredModel = {
        ...prev,
        host: prev.host || host || undefined,
        modelKey,
        watching: nextWatching,
      }

      setModelsByKey((p) => ({ ...p, [modelKey]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

      try {
        const updated = await patchModelFlags({
          ...(prev.id ? { id: prev.id } : {}),
          host: optimistic.host || '',
          modelKey,
          watched: nextWatching,
        })

        if (!updated) {
          setModelsByKey((p) => {
            const { [modelKey]: _drop, ...rest } = p
            return rest
          })
          if (prev.id) removeModelCache(prev.id)
          if (sameAsPlayer) setPlayerModel(null)
          window.dispatchEvent(
            new CustomEvent('models-changed', { detail: { removed: true, id: prev.id, modelKey } })
          )
          return
        }

        const k = lower(updated.modelKey || modelKey)
        if (k) setModelsByKey((p) => ({ ...p, [k]: updated }))
        upsertModelCache(updated)
        if (sameAsPlayer) setPlayerModel(updated)
        window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } }))
      } catch (e: any) {
        setModelsByKey((p) => ({ ...p, [modelKey]: prev }))
        upsertModelCache(prev)
        if (sameAsPlayer) setPlayerModel(prev)
        notify.error('Watched umschalten fehlgeschlagen', e?.message ?? String(e))
      } finally {
        delete flagsInFlightRef.current[guardKey]
      }
    },
    [notify, playerJob, patchModelFlags, upsertModelCache, removeModelCache, modelsByKey]
  )

  // Clipboard auto add/start (wie bei dir)
  useEffect(() => {
    if (!autoAddEnabled && !autoStartEnabled) return
    if (!navigator.clipboard?.readText) return

    let cancelled = false
    let inFlight = false
    let timer: number | null = null

    const checkClipboard = async () => {
      if (cancelled || inFlight) return
      inFlight = true
      try {
        const text = await navigator.clipboard.readText()
        const url0 = extractFirstUrl(text)
        if (!url0) return

        const norm0 = normalizeHttpUrl(url0)
        if (!norm0) return

        const provider = getProviderFromNormalizedUrl(norm0)
        if (!provider) return

        const url = canonicalizeProviderUrl(norm0)

        if (url === lastClipboardUrlRef.current) return
        lastClipboardUrlRef.current = url

        if (autoAddEnabled) setSourceUrl(url)

        if (autoStartEnabled) {
          // ✅ immer enqueue (dedupe verhindert doppelt)
          enqueueStart({ url, silent: false })
        }
      } catch {
        // ignore
      } finally {
        inFlight = false
      }
    }

    const schedule = (ms: number) => {
      if (cancelled) return
      timer = window.setTimeout(async () => {
        await checkClipboard()
        schedule(document.hidden ? 5000 : 1500)
      }, ms)
    }

    const kick = () => {
      void checkClipboard()
    }

    window.addEventListener('focus', kick)
    document.addEventListener('visibilitychange', kick)

    schedule(0)

    return () => {
      cancelled = true
      if (timer) window.clearTimeout(timer)
      window.removeEventListener('focus', kick)
      document.removeEventListener('visibilitychange', kick)
    }
  }, [autoAddEnabled, autoStartEnabled, enqueueStart])

  if (!authChecked) {
    return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
  }

  if (!authed) {
    return <LoginPage onLoggedIn={checkAuth} />
  }

  return (
    <div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
      <div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
        <div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
        <div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
      </div>

      <div className="relative">
        <header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
          <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
            <div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
              <div className="min-w-0">
                <div className="min-w-0">
                  <div className="flex items-center gap-3 min-w-0">
                    <h1 className="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
                      Recorder
                    </h1>

                    {/* ✅ Mobile: Icons + Counts direkt rechts neben Recorder */}
                    <div className="flex items-center gap-1.5 shrink-0">
                      <span
                        className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
                        title="online"
                      >
                        <SignalIcon className="size-4 opacity-80" />
                        <span className="tabular-nums">{onlineModelsCount}</span>
                      </span>

                      <span
                        className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
                        title="Watched online"
                      >
                        <EyeIcon className="size-4 opacity-80" />
                        <span className="tabular-nums">{onlineWatchedModelsCount}</span>
                      </span>

                      <span
                        className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
                        title="Fav online"
                      >
                        <HeartIcon className="size-4 opacity-80" />
                        <span className="tabular-nums">{onlineFavCount}</span>
                      </span>

                      <span
                        className="inline-flex items-center gap-1 rounded-full bg-gray-100/80 px-2 py-1 text-[11px] font-semibold text-gray-900 ring-1 ring-gray-200/60 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10"
                        title="Like online"
                      >
                        <HandThumbUpIcon className="size-4 opacity-80" />
                        <span className="tabular-nums">{onlineLikedCount}</span>
                      </span>
                    </div>
                    <div className="hidden sm:block text-[11px] text-gray-500 dark:text-gray-400">
                      <LastUpdatedText
                        enabled={Boolean(recSettings.useChaturbateApi)}
                        fetchedAt={cbOnlineFetchedAt}
                      />
                    </div>
                  </div>

                  {/* ✅ Mobile: Status volle Breite + PerfMonitor + Cookies nebeneinander */}
                  <div className="sm:hidden mt-1 w-full">
                    <div className="text-[11px] text-gray-500 dark:text-gray-400">
                      <LastUpdatedText
                        enabled={Boolean(recSettings.useChaturbateApi)}
                        fetchedAt={cbOnlineFetchedAt}
                      />
                    </div>
                    
                    <div className="mt-2 flex items-stretch gap-2">
                      {showPerfMon ? <PerformanceMonitor mode="inline" className="flex-1" /> : <div className="flex-1" />}

                      <Button
                        variant="secondary"
                        onClick={() => setCookieModalOpen(true)}
                        className="px-3 shrink-0"
                      >
                        Cookies
                      </Button>

                      <Button
                        variant="secondary"
                        onClick={logout}
                        className="px-3 shrink-0"
                      >
                        Abmelden
                      </Button>
                    </div>
                  </div>
                </div>
              </div>

              <div className="hidden sm:flex items-center gap-2 h-full">
                {showPerfMon ? <PerformanceMonitor mode="inline" /> : null}
                <Button variant="secondary" onClick={() => setCookieModalOpen(true)} className="h-9 px-3">
                  Cookies
                </Button>
                <Button variant="secondary" onClick={logout} className="h-9 px-3">
                  Abmelden
                </Button>
              </div>
            </div>

            <div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
              <div className="relative">
                <label className="sr-only">Source URL</label>
                <TextInput
                  ref={sourceUrlInputRef}
                  value={sourceUrl}
                  size='sm'
                  onChange={(e) => setSourceUrl(e.target.value)}
                  selectAllOnFocus
                  selectAllOnMouseDown
                  placeholder="https://…"
                />
              </div>

              <Button variant="primary" onClick={onStart} disabled={!canStart} className="w-full sm:w-auto rounded-lg">
                Start
              </Button>
            </div>

            {error ? (
              <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
                <div className="flex items-start justify-between gap-3">
                  <div className="min-w-0 break-words">{error}</div>
                  <button
                    type="button"
                    className="shrink-0 rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
                    onClick={() => setError(null)}
                    aria-label="Fehlermeldung schließen"
                    title="Schließen"
                  >
                    ✕
                  </button>
                </div>
              </div>
            ) : null}

            {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
              <div className="text-xs text-amber-700 dark:text-amber-300">
                ⚠️ Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
              </div>
            ) : null}

            {busy ? (
              <div className="pt-1">
                <ProgressBar label="Starte Download…" indeterminate />
              </div>
            ) : null}

            <div className="hidden sm:block pt-2">
              <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
            </div>
          </div>
        </header>

        <div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
          <div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
            <Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
          </div>
        </div>

        <main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-2 space-y-2">
          {selectedTab === 'running' ? (
            <Downloads
              jobs={jobs}
              modelsByKey={modelsByKey}
              roomStatusByModelKey={roomStatusByModelKey}
              pending={pendingWatchedRooms}
              autostartState={autostartState}
              onRefreshAutostartState={async () => {
                try {
                  const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
                  setAutostartState({
                    paused: Boolean(s?.paused),
                    pausedByUser: Boolean(s?.pausedByUser),
                    pausedByDisk: Boolean(s?.pausedByDisk),
                  })
                } catch {
                  // ignore
                }
              }}
              onOpenPlayer={openPlayer}
              onStopJob={stopJob}
              onStopAllJobs={stopAllJobs}
              onRemoveQueuedPostworkJob={removeQueuedPostworkJob}
              onToggleFavorite={handleToggleFavorite}
              onToggleLike={handleToggleLike}
              onToggleWatch={handleToggleWatch}
              onAddToDownloads={handleAddToDownloads}
              onRemovePending={handleRemovePendingWatchedRoom}
              blurPreviews={Boolean(recSettings.blurPreviews)}
              enableConcurrentDownloadsLimit={Boolean(recSettings.enableConcurrentDownloadsLimit)}
              maxConcurrentDownloads={Number(recSettings.maxConcurrentDownloads ?? 20)}
            />
          ) : null}

          {selectedTab === 'finished' ? (
            <FinishedDownloads
              jobs={jobs}
              modelsByKey={modelsByKey}
              doneJobs={doneJobs}
              doneTotal={doneCount}
              page={donePage}
              pageSize={donePageSize}
              onPageSizeChange={setDonePageSize}
              onPageChange={setDonePage}
              onOpenPlayer={openPlayer}
              onDeleteJob={handleDeleteJobWithUndo}
              onToggleHot={handleToggleHot}
              onToggleFavorite={handleToggleFavorite}
              onToggleLike={handleToggleLike}
              onToggleWatch={handleToggleWatch}
              onKeepJob={handleKeepJob}
              onSplitJob={openSplitModal}
              onAddToDownloads={handleAddToDownloads}
              blurPreviews={Boolean(recSettings.blurPreviews)}
              teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
              teaserAudio={Boolean(recSettings.teaserAudio)}
              pauseTeasers={Boolean(detailsModelKey) || splitModalOpen}
              assetNonce={assetNonce}
              sortMode={doneSort}
              onSortModeChange={(m) => {
                setDoneSort(m)
                setDonePage(1)
              }}
              loadMode="paged"
              includeKeep={includeKeep}
              onIncludeKeepChange={(checked) => {
                setIncludeKeep(checked)
                setDonePage(1)
                donePrefetchRef.current = null
              }}
            />
          ) : null}

          {selectedTab === 'models' ? (
            <ModelsTab onAddToDownloads={handleAddToDownloads} />
          ) : null}
          {selectedTab === 'categories' ? <CategoriesTab /> : null}
          {selectedTab === 'settings' ? (
            <RecorderSettings
              onAssetsGenerated={bumpAssets}
            />
          ) : null}
        </main>

        <CookieModal
          open={cookieModalOpen}
          onClose={() => setCookieModalOpen(false)}
          initialCookies={initialCookies}
          onApply={(list) => {
            const normalized = normalizeCookies(Object.fromEntries(list.map((c) => [c.name, c.value] as const)))
            setCookies(normalized)

            void apiJSON('/api/cookies', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ cookies: normalized }),
            }).catch(() => {})
          }}
        />

        <ModelDetails
          open={Boolean(detailsModelKey)}
          modelKey={detailsModelKey}
          onClose={() => setDetailsModelKey(null)}
          onOpenPlayer={openPlayer}
          runningJobs={jobs}
          cookies={cookies}
          blurPreviews={recSettings.blurPreviews}

          // ✅ neu: gleiche Teaser-Settings wie FinishedDownloads
          teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
          teaserAudio={Boolean(recSettings.teaserAudio)}

          onToggleHot={handleToggleHot}
          onDelete={handleDeleteJob}
          onKeep={handleKeepJob} // ✅ neu
          onToggleFavorite={handleToggleFavorite}
          onToggleLike={handleToggleLike}
          onToggleWatch={handleToggleWatch}
          onStopJob={stopJob}
        />

        <VideoSplitModal
          key={splitModalKey}
          open={splitModalOpen}
          job={splitJob}
          videoSrc={videoSrcFromJob(splitJob)}
          timelinePreviewCount={8}
          onClose={() => {
            setSplitModalOpen(false)
            setSplitJob(null)
          }}
          onApply={async ({ job, splits, segments }) => {
            console.log('VIDEO_SPLIT_APPLY', {
              jobId: job.id,
              output: job.output,
              splits,
              segments,
            })
          }}
        />

        {playerJob ? (
          <Player
            key={[
              String((playerJob as any)?.id ?? ''),
              baseName(playerJob.output || ''),
              // optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst
              String(assetNonce),
            ].join('::')}
            job={playerJob}
            modelKey={playerModelKey ?? undefined}
            modelsByKey={modelsByKey}
            expanded={playerExpanded}
            onToggleExpand={() => setPlayerExpanded((s) => !s)}
            onClose={() => {
              setPlayerJob(null)
              setPlayerStartAtSec(null)
            }}
            startAtSec={playerStartAtSec ?? undefined}
            isHot={baseName(playerJob.output || '').startsWith('HOT ')}
            isFavorite={Boolean(playerModel?.favorite)}
            isLiked={playerModel?.liked === true}
            isWatching={Boolean(playerModel?.watching)}
            onKeep={handleKeepJob}
            onDelete={handleDeleteJob}
            onToggleHot={handleToggleHot}
            onToggleFavorite={handleToggleFavorite}
            onToggleLike={handleToggleLike}
            onStopJob={stopJob}
            onToggleWatch={handleToggleWatch}
          />
        ) : null}
      </div>
    </div>
  )
}
