// frontend\src\App.tsx

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

const COOKIE_STORAGE_KEY = 'record_cookies'

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

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

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

const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
  recordDir: 'records',
  doneDir: 'records/done',
  ffmpegPath: '',
  autoAddToDownloadList: false,
  autoStartAddedDownloads: false,
  useChaturbateApi: false,
  useMyFreeCamsWatcher: false,
  autoDeleteSmallDownloads: false,
  autoDeleteSmallDownloadsBelowMB: 200,
  blurPreviews: false,
  teaserPlayback: 'hover',
  teaserAudio: false,
  lowDiskPauseBelowGB: 3000,
}

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

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

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

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

type ChaturbateOnlineResponse = {
  enabled: boolean
  rooms: ChaturbateOnlineRoom[]
  total?: number
}

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

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

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

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

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

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

type Provider = 'chaturbate' | 'mfc'

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

  return base ? base : null
}

export default function App() {

  const [authChecked, setAuthChecked] = useState(false)
  const [authed, setAuthed] = useState(false)

  const checkAuth = useCallback(async () => {
    try {
      const res = await apiJSON<{ authenticated?: boolean }>('/api/auth/me', { cache: 'no-store' as any })
      setAuthed(Boolean(res?.authenticated))
    } catch {
      setAuthed(false)
    } finally {
      setAuthChecked(true)
    }
  }, [])

  const logout = useCallback(async () => {
    try {
      // Backend löscht Session + Cookie (204)
      await fetch('/api/auth/logout', { method: 'POST', cache: 'no-store' as any })
    } catch {
      // ignore
    } finally {
      // UI/State reset
      setAuthed(false)
      setAuthChecked(true)

      setError(null)
      setBusy(false)

      // optional: UI zurücksetzen, damit nach erneutem Login alles "clean" ist
      setSelectedTab('running')
      setPlayerJob(null)
      setPlayerExpanded(false)
      setDetailsModelKey(null)

      // optional: Listen leeren (verhindert kurz sichtbare "alte" Daten beim Login-Screen)
      setJobs([])
      setDoneJobs([])
      setDoneCount(0)
      setDonePage(1)

      setModelsByKey({})
      setModelsCount(0)

      setCbOnlineByKeyLower({})
      cbOnlineByKeyLowerRef.current = {}
      startedToastByJobIdRef.current = {}
      jobsInitDoneRef.current = false
      setPendingWatchedRooms([])
      setPendingAutoStartByKey({})

      setOnlineModelsCount(0)

      // optional: URL-Feld leeren
      setSourceUrl('')
    }
  }, [])

  useEffect(() => {
    void checkAuth()
  }, [checkAuth])

  const notify = useNotify()

  const notifyRef = useRef(notify)

    // ✅ Dedupe für "Cookies fehlen" Meldung (damit silent/autostarts nicht spammen)
  const cookieProblemLastAtRef = useRef(0)

  const isCookieGateError = (msg: string) => {
    const m = (msg || '').toLowerCase()
    return (
      m.includes('altersverifikationsseite erhalten') ||
      m.includes('verify your age') ||
      m.includes('schutzseite von cloudflare erhalten') ||
      m.includes('just a moment') ||
      m.includes('kein room-html')
    )
  }

  const showMissingCookiesMessage = (opts?: { silent?: boolean }) => {
    const silent = Boolean(opts?.silent)

    const title = 'Cookies fehlen oder sind abgelaufen'
    const body =
      'Der Recorder hat statt des Room-HTML eine Schutz-/Altersverifikationsseite erhalten. ' +
      'Bitte Cookies aktualisieren (bei Chaturbate z.B. cf_clearance + sessionId) und erneut starten.'

    // Wenn Nutzer aktiv klickt: oben als Error-Box zeigen + Cookie-Modal anbieten
    if (!silent) {
      setError(`⚠️ ${title}. ${body}`)
      // optional aber hilfreich: Modal direkt öffnen
      setCookieModalOpen(true)
      return
    }

    // Bei silent (Auto-Start / Queue): nur selten Toast
    const now = Date.now()
    if (now - cookieProblemLastAtRef.current > 15_000) {
      cookieProblemLastAtRef.current = now
      notifyRef.current?.error(title, body)
    }
  }

  useEffect(() => {
    notifyRef.current = notify
  }, [notify])

    // ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
  const [showPerfMon, setShowPerfMon] = useState(false)

  useEffect(() => {
    const w = window as any
    const id =
      typeof w.requestIdleCallback === 'function'
        ? w.requestIdleCallback(() => setShowPerfMon(true), { timeout: 1500 })
        : window.setTimeout(() => setShowPerfMon(true), 800)

    return () => {
      if (typeof w.cancelIdleCallback === 'function') w.cancelIdleCallback(id)
      else window.clearTimeout(id)
    }
  }, [])

  const DONE_PAGE_SIZE = 8

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

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

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

  const donePrefetchRef = useRef<DonePrefetch | null>(null)
  const donePrefetchInFlightRef = useRef(false)

  // ✅ verhindert "pending forever": immer nur 1 done-fetch gleichzeitig
  const doneFetchAbortRef = useRef<AbortController | null>(null)
  const doneFetchInFlightRef = useRef(false)


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

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

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

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

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

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

  const loadDoneCount = useCallback(async () => {
    try {
      // ✅ leichtgewichtiger Endpoint (siehe Backend unten)
      const res = await fetch(`/api/record/done/meta`, { cache: 'no-store' as any })
      if (!res.ok) return

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

      setDoneCount(count)
      setLastHeaderUpdateAtMs(Date.now())
    } catch {
      // ignore
    }
  }, [])

  // ✅ sagt FinishedDownloads: "bitte ALL neu laden"
  const requestFinishedReload = useCallback(() => {
    window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
  }, [])

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

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

      // akzeptiere: Array oder { items: [] }
      const items = Array.isArray(data)
        ? (data as RecordJob[])
        : Array.isArray(data?.items)
          ? (data.items as RecordJob[])
          : []

      setJobs(items)
      jobsRef.current = items
      setLastHeaderUpdateAtMs(Date.now())
    } catch {
      // ignore
    }
  }, [])

  useEffect(() => {
    try {
      window.localStorage.setItem(DONE_SORT_KEY, doneSort)
    } catch {}
  }, [doneSort])

  useEffect(() => {
    if (!authed) return
    void loadDoneCount()
  }, [authed, loadDoneCount])

  const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
  const [sourceUrl, setSourceUrl] = useState('')
  const [jobs, setJobs] = useState<RecordJob[]>([])
  const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
  const [donePage, setDonePage] = useState(1)
  const [doneCount, setDoneCount] = useState<number>(0)
  const [modelsCount, setModelsCount] = useState(0)

  const [onlineModelsCount, setOnlineModelsCount] = useState(0)

  const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
  const [nowMs, setNowMs] = useState<number>(() => Date.now())
  useEffect(() => {
    const t = window.setInterval(() => setNowMs(Date.now()), 1000)
    return () => window.clearInterval(t)
  }, [])

  const lower = (s: string) => (s || '').toLowerCase().trim()

  const formatAgoDE = (diffMs: number) => {
    const s = Math.max(0, Math.floor(diffMs / 1000))
    if (s < 2) return 'gerade eben'
    if (s < 60) return `vor ${s} Sekunden`

    const m = Math.floor(s / 60)
    if (m === 1) return 'vor 1 Minute'
    if (m < 60) return `vor ${m} Minuten`

    const h = Math.floor(m / 60)
    if (h === 1) return 'vor 1 Stunde'
    return `vor ${h} Stunden`
  }

  const headerUpdatedText = useMemo(() => {
    const diff = nowMs - lastHeaderUpdateAtMs
    return `(zuletzt aktualisiert: ${formatAgoDE(diff)})`
  }, [nowMs, lastHeaderUpdateAtMs])

  const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})

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

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

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

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

    return map
  }, [])

  const refreshModelsByKey = useCallback(async () => {
    try {
      const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
      setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : []))
      setLastHeaderUpdateAtMs(Date.now())
    } catch {
      // ignore
    }
  }, [buildModelsByKey])

  const [playerModel, setPlayerModel] = useState<StoredModel | null>(null)
  const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null)

  const [detailsModelKey, setDetailsModelKey] = useState<string | null>(null)

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

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

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

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

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

      if (keyLower) setDetailsModelKey(keyLower)
    }

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

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

  useEffect(() => {
    // initial laden
    void refreshModelsByKey()

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

      // ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models/list)
      if (updated && typeof updated === 'object') {
        const k = String(updated.modelKey ?? '').toLowerCase().trim()
        if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated }))

        try {
          upsertModelCache(updated)
        } catch {}

        setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev))
        setLastHeaderUpdateAtMs(Date.now())
        return
      }

      // ✅ 2) Delete-Event (optional): ebenfalls ohne Voll-Refresh
      if (detail?.removed) {
        const removedId = String(detail?.id ?? '').trim()
        const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim()

        if (removedKey) {
          setModelsByKey((prev) => {
            const { [removedKey]: _drop, ...rest } = prev
            return rest
          })
        }

        if (removedId) {
          setPlayerModel((prev) => (prev?.id === removedId ? null : prev))
        }

        setLastHeaderUpdateAtMs(Date.now())
        return
      }

      // ✅ 3) Nur wenn kein Model/keine Delete-Info mitkommt: kompletter Refresh
      void refreshModelsByKey()
    }

    window.addEventListener('models-changed', onChanged as any)
    return () => window.removeEventListener('models-changed', onChanged as any)
  }, [refreshModelsByKey, upsertModelCache])

  const [error, setError] = useState<string | null>(null)
  const [busy, setBusy] = useState(false)
  const [cookieModalOpen, setCookieModalOpen] = useState(false)
  const [cookies, setCookies] = useState<Record<string, string>>({})
  const [cookiesLoaded, setCookiesLoaded] = useState(false)
  const [selectedTab, setSelectedTab] = useState('running')
  const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
  const [playerExpanded, setPlayerExpanded] = useState(false)

  const [assetNonce, setAssetNonce] = useState(0)
  const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])

  const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
  const recSettingsRef = useRef(recSettings)
  useEffect(() => {
    recSettingsRef.current = recSettings
  }, [recSettings])

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

  const [pendingWatchedRooms, setPendingWatchedRooms] = useState<PendingWatchedRoom[]>([])
  const [pendingAutoStartByKey, setPendingAutoStartByKey] = useState<Record<string, string>>({})

  // "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet)
  const busyRef = useRef(false)
  const cookiesRef = useRef<Record<string, string>>({})
  const jobsRef = useRef<RecordJob[]>([])

  // ✅ "Job gestartet" Toast: dedupe (auch gegen SSE/polling) + initial-load suppression
  const startedToastByJobIdRef = useRef<Record<string, true>>({})
  const jobsInitDoneRef = useRef(false)

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

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

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

  type StartQueueItem = {
    url: string
    silent: boolean
    pendingKeyLower?: string // wenn aus pendingAutoStartByKey kommt
  }

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

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

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

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

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

      // pump einmal pro Tick schedulen
      if (!pumpStartQueueScheduledRef.current) {
        pumpStartQueueScheduledRef.current = true
        queueMicrotask(() => {
          pumpStartQueueScheduledRef.current = false
          void pumpStartQueue()
        })
      }
      return true
    },
    // pumpStartQueue kommt gleich darunter (useCallback), daher eslint ggf. meckert -> ok, wir definieren pumpStartQueue als function declaration unten
    []
  )

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

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

    try {
      const currentCookies = cookiesRef.current

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

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

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

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

      if (created?.id) startedToastByJobIdRef.current[String(created.id)] = true

      // UI sofort aktualisieren (optional)
      setJobs((prev) => [created, ...prev])
      jobsRef.current = [created, ...jobsRef.current]

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

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

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

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

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

          // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen
          if (ok && next.pendingKeyLower) {
            const kLower = next.pendingKeyLower
            setPendingAutoStartByKey((prev) => {
              const copy = { ...(prev || {}) }
              delete copy[kLower]
              pendingAutoStartByKeyRef.current = copy
              return copy
            })
          }
        } finally {
          // dedupe wieder freigeben
          startQueuedSetRef.current.delete(next.url)

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

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

  // ✅ Zentraler Snapshot: username(lower) -> room
  const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
  const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})

  const lastCbShowByKeyLowerRef = useRef<Record<string, string>>({})

  // ✅ merkt sich, ob ein Model im letzten Snapshot überhaupt online war
  const lastCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})

  // ✅ verhindert Toast-Spam direkt beim ersten Poll (Startup)
  const cbOnlineInitDoneRef = useRef(false)

  // ✅ merkt sich, ob ein Model seit App-Start schon einmal online war
  const everCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})

  useEffect(() => {
    cbOnlineByKeyLowerRef.current = cbOnlineByKeyLower
  }, [cbOnlineByKeyLower])

  const isChaturbateStoreModel = useCallback((m?: StoredModel | null) => {
    const h = String(m?.host ?? '').toLowerCase()
    const input = String(m?.input ?? '').toLowerCase()
    return h.includes('chaturbate') || input.includes('chaturbate.com')
  }, [])

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

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

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

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

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

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

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

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

    const currentCookies = cookiesRef.current

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

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

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

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

    // ✅ Chaturbate: parse modelKey + queue-logic über Snapshot
    if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) {
      try {
        const parsed = await apiJSON<ParsedModel>('/api/models/parse', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ input: norm }),
        })

        const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase()
        if (mkLower) {
          // 1) Wenn busy: IMMER queue (auch public, damit "public queued" sichtbar bleibt)
          if (busyRef.current) {
            setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
            return true
          }

          // 2) Wenn Snapshot sagt: online aber NICHT public -> queue
          const room = cbOnlineByKeyLowerRef.current[mkLower]
          const show = String(room?.current_show ?? '')
          if (room && show && show !== 'public') {
            setPendingAutoStartByKey((prev) => ({ ...(prev || {}), [mkLower]: norm }))
            return true
          }
        }
      } catch {
        // parse fail -> normal starten
      }
    } else {
      // Nicht-Chaturbate-API: wenn busy, wenigstens "pendingStart" setzen
      if (busyRef.current) {
        pendingStartUrlRef.current = norm
        return true
      }
    }

    if (busyRef.current) return false
    setBusy(true)
    busyRef.current = true

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

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

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

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

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

      if (!silent) setError(msg)
      return false
    } finally {
      setBusy(false)
      busyRef.current = false
    }
  }, [])

  // ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility
  useEffect(() => {
    let cancelled = false

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

    const onUpdated = () => void load()
    const onFocus = () => void load()

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

    load()

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

  // ✅ Models-Count (leicht)
  useEffect(() => {
    let cancelled = false

    const load = async () => {
      try {
        const meta = await apiJSON<{ count?: number }>('/api/models/meta', { cache: 'no-store' })
        const c = Number(meta?.count ?? 0)
        if (!cancelled && Number.isFinite(c)) {
          setModelsCount(c)
          setLastHeaderUpdateAtMs(Date.now())
        }
      } catch {
        // ignore
      }
    }

    load()
    const t = window.setInterval(load, document.hidden ? 60000 : 30000)
    return () => {
      cancelled = true
      window.clearInterval(t)
    }
  }, [])

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

  const openPlayer = useCallback((job: RecordJob) => {
    modelsCacheRef.current = null
    setPlayerModel(null)
    setPlayerJob(job)
    setPlayerExpanded(false)
  }, [])

  const runningJobs = jobs.filter((j) => {
    const s = String((j as any)?.status ?? '').toLowerCase()
    return s === 'running' || s === 'postwork'
  })

  // ✅ Anzahl Watched Models (aus Store), die online sind
  const onlineWatchedModelsCount = useMemo(() => {
    let c = 0
    for (const m of Object.values(modelsByKey)) {
      if (!m?.watching) continue
      if (!isChaturbateStoreModel(m)) continue

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

      if (cbOnlineByKeyLower[k]) c++
    }
    return c
  }, [modelsByKey, cbOnlineByKeyLower, isChaturbateStoreModel])

  const { onlineFavCount, onlineLikedCount } = useMemo(() => {
    let fav = 0
    let liked = 0

    for (const m of Object.values(modelsByKey)) {
      const k = lower(String(m?.modelKey ?? ''))
      if (!k) continue
      if (!cbOnlineByKeyLower[k]) continue

      if (m?.favorite) fav++
      if (m?.liked === true) liked++
    }

    return { onlineFavCount: fav, onlineLikedCount: liked }
  }, [modelsByKey, cbOnlineByKeyLower])

  const tabs: TabItem[] = [
    { id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
    { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
    { id: 'models', label: 'Models', count: modelsCount },
    { id: 'categories', label: 'Kategorien' },
    { id: 'settings', label: 'Einstellungen' },
  ]

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

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

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

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

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

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

  useEffect(() => {
    // 1x initial / bei sort-wechsel (für Badge)
    void loadDoneCount()

    const onVis = () => {
      if (!document.hidden) void loadDoneCount()
    }
    document.addEventListener('visibilitychange', onVis)

    return () => {
      document.removeEventListener('visibilitychange', onVis)
    }
  }, [loadDoneCount])

  const refreshDoneNow = useCallback(
    async (preferPage?: number) => {
      // ✅ wenn noch ein done-fetch läuft: abbrechen (sonst stauen sich Requests)
      if (doneFetchInFlightRef.current) {
        doneFetchAbortRef.current?.abort()
      }

      const ac = new AbortController()
      doneFetchAbortRef.current = ac
      doneFetchInFlightRef.current = true

      try {
        const wanted = typeof preferPage === 'number' ? preferPage : donePage

        const res = await fetch(
          `/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
            `&sort=${encodeURIComponent(doneSort)}&withCount=1`,
          { cache: 'no-store' as any, signal: ac.signal }
        )

        if (!res.ok) throw new Error(`HTTP ${res.status}`)

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

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

        const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
        const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length

        setDoneCount(count)

        const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
        const target = Math.min(Math.max(1, wanted), maxPage)
        if (target !== donePage) setDonePage(target)

        // Wenn wir auf eine andere Page clampen mussten: die richtige Page nachladen
        if (target !== wanted) {
          const res2 = await fetch(
            `/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
              `&sort=${encodeURIComponent(doneSort)}&withCount=1`,
            { cache: 'no-store' as any, signal: ac.signal }
          )
          if (!res2.ok) throw new Error(`HTTP ${res2.status}`)
          const data2 = await res2.json().catch(() => null)
          const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
          setDoneJobs(items2)
        } else {
          setDoneJobs(items)
        }

        setLastHeaderUpdateAtMs(Date.now())
      } catch (e: any) {
        // Abort ist ok
        if (String(e?.name) !== 'AbortError') {
          // optional: console.debug('[DONE] refresh failed', e)
        }
      } finally {
        // ✅ Nur der "aktuelle" Request darf den InFlight-Status zurücksetzen.
        //    Sonst kann ein älterer (abgebrochener) Request einen neueren überschreiben.
        const isCurrent = doneFetchAbortRef.current === ac
        if (isCurrent) {
          doneFetchAbortRef.current = null
          doneFetchInFlightRef.current = false
        }
      }
    },
    [donePage, doneSort]
  )


  useEffect(() => {
    if (selectedTab !== 'finished') return

    // ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
    void loadDoneCount()
    requestFinishedReload()

    const onVis = () => {
      if (!document.hidden) {
        void loadDoneCount()
        requestFinishedReload()
      }
    }
    document.addEventListener('visibilitychange', onVis)

    return () => {
      document.removeEventListener('visibilitychange', onVis)
    }
  }, [selectedTab, loadDoneCount, requestFinishedReload])
  

  useEffect(() => {
    const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
    if (donePage > maxPage) setDonePage(maxPage)
  }, [doneCount, donePage])

  // jobs SSE / polling (mit "Job gestartet" Toast für Backend-Autostarts)
  useEffect(() => {
    if (!authed) return

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

    const stopPoll = () => {
      if (timer != null) {
        window.clearInterval(timer)
        timer = null
      }
    }

    const startPoll = () => {
      if (timer != null) return
      timer = window.setInterval(() => {
        if (document.hidden) return
        if (selectedTabRef.current === 'finished') {
          void loadDoneCount()
          requestFinishedReload()
        } else {
          void loadDoneCount()
        }
      }, document.hidden ? 60000 : 15000)
    }

    const lastFireRef = { t: 0 }
    let coalesceTimer: number | null = null

    const requestRefresh = () => {
      const now = Date.now()
      const since = now - lastFireRef.t

      // coalesce bursts
      if (since < 800) {
        if (coalesceTimer != null) return
        coalesceTimer = window.setTimeout(() => {
          coalesceTimer = null
          lastFireRef.t = Date.now()
          if (selectedTabRef.current === 'finished') {
            void loadDoneCount()
            requestFinishedReload()
          } else {
            void loadDoneCount()
          }
        }, 900)
        return
      }

      lastFireRef.t = now
      if (selectedTabRef.current === 'finished') {
        void loadDoneCount()
        requestFinishedReload()
      } else {
        void loadDoneCount()
      }
    }

    // initial
    void loadDoneCount()

    es = new EventSource('/api/record/done/stream')

    es.onopen = () => {
      // ✅ sobald SSE stabil da ist: Poll aus
      stopPoll()
    }

    es.onerror = () => {
      // ✅ SSE kaputt -> Poll an
      startPoll()
    }

    const onDone = () => requestRefresh()
    es.addEventListener('doneChanged', onDone as any)

    const onVis = () => {
      if (!document.hidden) requestRefresh()
    }
    document.addEventListener('visibilitychange', onVis)

    return () => {
      document.removeEventListener('visibilitychange', onVis)
      if (coalesceTimer != null) window.clearTimeout(coalesceTimer)
      stopPoll()
      es?.removeEventListener('doneChanged', onDone as any)
      es?.close()
      es = null
    }
  }, [authed, loadDoneCount, requestFinishedReload])

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

    // initial
    void loadJobs()

    // polling: schneller wenn running-tab offen oder jobs laufen
    const t = window.setInterval(() => {
      if (document.hidden) return

      const hasRunning = jobsRef.current.some((j) => {
        const s = String((j as any)?.status ?? '').toLowerCase()
        return s === 'running' || s === 'postwork'
      })

      // wenn Tab "running" offen ODER irgendwas läuft -> häufiger pollen
      if (selectedTabRef.current === 'running' || hasRunning) {
        void loadJobs()
      }
    }, document.hidden ? 60000 : 3000) // 3s fühlt sich "live" an

    const onVis = () => {
      if (!document.hidden) void loadJobs()
    }
    document.addEventListener('visibilitychange', onVis)

    return () => {
      window.clearInterval(t)
      document.removeEventListener('visibilitychange', onVis)
    }
  }, [authed, loadJobs])

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

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

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

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

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

      if (!Number.isFinite(delta) || delta === 0) {
        void loadDoneCount()
        requestFinishedReload()
        return
      }

      // ✅ Tabs sofort updaten (optimistisch)
      setDoneCount((c) => Math.max(0, c + delta))

      // ✅ danach server-truth holen + ALL reload
      void loadDoneCount()
      requestFinishedReload()
    }

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

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

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

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

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

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

  async function onStart() {
    return startUrl(sourceUrl)
  }

  const handleAddToDownloads = useCallback(
    async (job: RecordJob): Promise<boolean> => {
      const raw = String((job as any)?.sourceUrl ?? '')
      const url0 = extractFirstUrl(raw)
      if (!url0) return false

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

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

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

      return ok
    },
    [startUrl, notify]
  )

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

      window.dispatchEvent(
        new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } })
      )

      try {
        // ✅ Wichtig: JSON-Response lesen, weil undoToken dort drin steckt
        const data = await apiJSON<{ undoToken?: string }>(
          `/api/record/delete?file=${encodeURIComponent(file)}`,
          { method: 'POST' }
        )

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

        window.setTimeout(() => {
          setDoneJobs((prev) => {
            const filtered = prev.filter((j) => baseName(j.output || '') !== file)

            // ✅ sofort auffüllen, wenn wir Platz haben
            const need = DONE_PAGE_SIZE - filtered.length
            if (need <= 0) return filtered

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

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

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

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

            // buffer zurückschreiben (mit verkürzter items-Liste)
            donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }

            return next
          })

          // ✅ Count sofort optimistisch runter
          setDoneCount((c) => Math.max(0, c - 1))

          // ✅ Player / jobs cleanup wie bei dir
          setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
          setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))

          // ✅ Buffer direkt wieder nachfüllen (background)
          void prefetchDonePage(donePage + 1)
        }, 320)

        const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
        return undoToken ? { undoToken } : {} // ✅ kein null mehr
      } catch (e: any) {
        window.dispatchEvent(
          new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } })
        )
        notify.error('Löschen fehlgeschlagen: ', file)
        return // ✅ void statt null
      }
    },
    [notify, refreshDoneNow]
  )

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

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

      window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'start' as const } }))

      try {
        await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' })
        window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'success' as const } }))

        window.setTimeout(() => {
          setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
          setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
          setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
        }, 320)
        
      } catch (e: any) {
        window.dispatchEvent(new CustomEvent('finished-downloads:delete', { detail: { file, phase: 'error' as const } }))
        notify.error('Keep fehlgeschlagen', file)
        return
      }
    },
    [selectedTab, refreshDoneNow, notify]
  )

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

      try {
        // ✅ Player/Preview soll den alten File-Key loslassen, bevor umbenannt wird
        window.dispatchEvent(new CustomEvent('player:release', { detail: { file } }))
        // kurze Pause hilft in der Praxis, wenn Video.js/Browser noch “dran” hängt
        await new Promise((r) => window.setTimeout(r, 60))

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

        // ✅ FinishedDownloads lokal syncen (wenn Rename außerhalb der Liste passiert, z.B. im Player)
        window.dispatchEvent(
          new CustomEvent('finished-downloads:rename', {
            detail: { oldFile: res.oldFile, newFile: res.newFile },
          })
        )

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

        // ✅ 1) Player immer updaten
        setPlayerJob((prev) => (prev ? { ...prev, output: apply(prev.output || '') } : prev))

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

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

        return res
      } catch (e: any) {
        notify.error('Umbenennen fehlgeschlagen', e?.message ?? String(e))
        return
      }
    },
    [notify]
  )

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

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

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

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

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

      // helper: host schnell aus job holen (für flags ohne id)
      const hostFromJob = (j: RecordJob): string => {
        try {
          const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
          const u = extractFirstUrl(urlFromJob)
          if (!u) return ''
          return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
        } catch {
          return ''
        }
      }

      // ✅ Fast-Path: modelKey aus Dateiname => instant optimistic + 1x flags call
      const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
      if (keyFromFile) {
        const guardKey = keyFromFile
        if (flagsInFlightRef.current[guardKey]) return
        flagsInFlightRef.current[guardKey] = true

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

        const nextFav = !Boolean(prev.favorite)

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

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

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

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

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

        return
      }

      // ✅ Fallback: keinen modelKey aus Filename -> wie bisher resolve+ensure
      let m = sameAsPlayer ? playerModel : null
      if (!m) m = await resolveModelForJob(job, { ensure: true })
      if (!m) return

      const guardKey = lower(m.modelKey || m.id || '')
      if (!guardKey) return

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

      const prev = m
      const nextFav = !Boolean(prev.favorite)

      const optimistic: StoredModel = {
        ...prev,
        favorite: nextFav,
        liked: nextFav ? false : prev.liked,
      }

      const kPrev = lower(prev.modelKey || '')
      if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

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

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

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


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

      const hostFromJob = (j: RecordJob): string => {
        try {
          const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
          const u = extractFirstUrl(urlFromJob)
          if (!u) return ''
          return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
        } catch {
          return ''
        }
      }

      const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
      if (keyFromFile) {
        const guardKey = keyFromFile
        if (flagsInFlightRef.current[guardKey]) return
        flagsInFlightRef.current[guardKey] = true

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

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

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

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

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

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

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

        return
      }

      // fallback
      let m = sameAsPlayer ? playerModel : null
      if (!m) m = await resolveModelForJob(job, { ensure: true })
      if (!m) return

      const guardKey = lower(m.modelKey || m.id || '')
      if (!guardKey) return

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

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

      const optimistic: StoredModel = {
        ...prev,
        liked: nextLiked,
        favorite: nextLiked ? false : prev.favorite,
      }

      const kPrev = lower(prev.modelKey || '')
      if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

      try {
        const updated = nextLiked
          ? await patchModelFlags({ id: prev.id, liked: true, favorite: false })
          : await patchModelFlags({ id: prev.id, liked: false })

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

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


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

      const hostFromJob = (j: RecordJob): string => {
        try {
          const urlFromJob = String((j as any).sourceUrl ?? (j as any).SourceURL ?? '')
          const u = extractFirstUrl(urlFromJob)
          if (!u) return ''
          return new URL(u).hostname.replace(/^www\./i, '').toLowerCase()
        } catch {
          return ''
        }
      }

      const keyFromFile = (modelKeyFromFilename(job.output || '') || '').trim().toLowerCase()
      if (keyFromFile) {
        const guardKey = keyFromFile
        if (flagsInFlightRef.current[guardKey]) return
        flagsInFlightRef.current[guardKey] = true

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

        const nextWatching = !Boolean(prev.watching)

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

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

        try {
          const updated = await patchModelFlags({
            ...(optimistic.id ? { id: optimistic.id } : {}),
            host: optimistic.host || hostFromJob(job) || '',
            modelKey: keyFromFile,
            watched: nextWatching, // ✅ API key watched => watching in DB
          })

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

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

        return
      }

      // fallback
      let m = sameAsPlayer ? playerModel : null
      if (!m) m = await resolveModelForJob(job, { ensure: true })
      if (!m) return

      const guardKey = lower(m.modelKey || m.id || '')
      if (!guardKey) return

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

      const prev = m
      const nextWatching = !Boolean(prev.watching)

      const optimistic: StoredModel = {
        ...prev,
        watching: nextWatching,
      }

      const kPrev = lower(prev.modelKey || '')
      if (kPrev) setModelsByKey((p) => ({ ...p, [kPrev]: optimistic }))
      upsertModelCache(optimistic)
      if (sameAsPlayer) setPlayerModel(optimistic)

      try {
        const updated = await patchModelFlags({ id: prev.id, watched: nextWatching })

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

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

  async function resolveModelForJob(
    job: RecordJob,
    opts?: { ensure?: boolean }
  ): Promise<StoredModel | null> {
    const wantEnsure = Boolean(opts?.ensure)

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

    const resolveByKey = async (key: string): Promise<StoredModel | null> => {
      if (!key) return null

      const needle = key.trim().toLowerCase()
      if (!needle) return null

      // ✅ 0) Sofort aus App-State (schnellster Pfad)
      const stateHit = modelsByKey[needle]
      if (stateHit) {
        upsertCache(stateHit)
        return stateHit
      }

      // ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models/list)
      if (wantEnsure) {
        let host: string | undefined

        // versuche Host aus sourceUrl zu nehmen (falls vorhanden)
        try {
          const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
          const url = extractFirstUrl(urlFromJob)
          if (url) host = new URL(url).hostname.replace(/^www\./i, '').toLowerCase()
        } catch {}

        const ensured = await apiJSON<StoredModel>('/api/models/ensure', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ modelKey: key, ...(host ? { host } : {}) }),
        })
        upsertModelCache(ensured)
        return ensured
      }

      // ✅ 2) Cache refreshen / initial füllen (nur wenn KEIN ensure)
      const now = Date.now()
      const cached = modelsCacheRef.current

      if (!cached || now - cached.ts > 30_000) {
        // erst aus State seeden (falls vorhanden)
        const seeded = Object.values(modelsByKey)
        if (seeded.length) {
          modelsCacheRef.current = { ts: now, list: seeded }
        } else {
          const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
          modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] }
        }
      }

      const list = modelsCacheRef.current?.list ?? []

      // ✅ 3) im Cache suchen
      const hit = list.find((m) => (m.modelKey || '').trim().toLowerCase() === needle)
      if (hit) return hit

      return null
    }

    const keyFromFile = modelKeyFromFilename(job.output || '')

    // ✅ Wichtig: IMMER zuerst Dateiname (auch bei running)
    // -> spart /parse + /upsert und macht Toggle instant
    if (keyFromFile) {
      return resolveByKey(keyFromFile)
    }

    const isRunning = job.status === 'running'

    // ✅ Nur wenn wir wirklich KEINEN Key aus dem Output haben:
    const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string
    const url = extractFirstUrl(urlFromJob)

    if (isRunning && url) {
      const parsed = await apiJSON<any>('/api/models/parse', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ input: url }),
      })

      const saved = await apiJSON<StoredModel>('/api/models/upsert', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(parsed),
      })

      upsertModelCache(saved)
      return saved
    }

    // Fallback: wenn gar nix geht
    return null
  }

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

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

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

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

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

        const url = canonicalizeProviderUrl(norm0)

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

        if (autoAddEnabled) setSourceUrl(url)

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

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

    const kick = () => void checkClipboard()
    window.addEventListener('hover', kick)
    document.addEventListener('visibilitychange', kick)

    schedule(0)

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

  useEffect(() => {
    const stop = startChaturbateOnlinePolling({
      getModels: () => {
        if (!recSettingsRef.current.useChaturbateApi) return []

        const modelsMap = modelsByKeyRef.current
        const pendingMap = pendingAutoStartByKeyRef.current

        const watchedKeysLower = Object.values(modelsMap)
          .filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
          .map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
          .filter(Boolean)

        const queuedKeysLower = Object.keys(pendingMap || {})
          .map((k) => String(k || '').trim().toLowerCase())
          .filter(Boolean)

        // ✅ NUR watched + queued pollen (Store kann riesig sein -> lag)
        // Wenn du Store-Online später willst: extra, seltener Poll (z.B. 60s) separat lösen.
        return Array.from(new Set([...watchedKeysLower, ...queuedKeysLower]))
      },

      getShow: () => ['public', 'private', 'hidden', 'away'],

      intervalMs: 8000,

      onData: (data: ChaturbateOnlineResponse) => {
        void (async () => {
          if (!data?.enabled) {
            setCbOnlineByKeyLower({})
            cbOnlineByKeyLowerRef.current = {}
            lastCbShowByKeyLowerRef.current = {}
            setPendingWatchedRooms([])
            everCbOnlineByKeyLowerRef.current = {}
            cbOnlineInitDoneRef.current = false
            lastCbOnlineByKeyLowerRef.current = {}
            setLastHeaderUpdateAtMs(Date.now())
            return
          }

          const nextSnap: Record<string, ChaturbateOnlineRoom> = {}
          for (const r of Array.isArray(data.rooms) ? data.rooms : []) {
            const u = String(r?.username ?? '').trim().toLowerCase()
            if (u) nextSnap[u] = r
          }

          setCbOnlineByKeyLower(nextSnap)
          cbOnlineByKeyLowerRef.current = nextSnap

          // ✅ Toasts: (A) watched offline->online, (B) waiting->public, (C) online->offline->online => "wieder online"
          try {
            const notificationsOn = Boolean((recSettingsRef.current as any).enableNotifications ?? true)
            const waiting = new Set(['private', 'away', 'hidden'])

            // watched-Keys (nur Chaturbate)
            const watchedSetLower = new Set(
              Object.values(modelsByKeyRef.current || {})
                .filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
                .map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
                .filter(Boolean)
            )

            const prevShow = lastCbShowByKeyLowerRef.current || {}
            const nextShowMap: Record<string, string> = { ...prevShow }

            const prevOnline = lastCbOnlineByKeyLowerRef.current || {}
            const isInitial = !cbOnlineInitDoneRef.current

            // ✅ "war schon mal online" Snapshot (vor diesem Poll)
            const everOnline = everCbOnlineByKeyLowerRef.current || {}
            const nextEverOnline: Record<string, true> = { ...everOnline }

            for (const [keyLower, room] of Object.entries(nextSnap)) {
              const nowShow = String((room as any)?.current_show ?? '').toLowerCase().trim()
              const beforeShow = String(prevShow[keyLower] ?? '').toLowerCase().trim()

              const wasOnline = Boolean(prevOnline[keyLower])
              const isOnline = true // weil es in nextSnap ist
              const becameOnline = isOnline && !wasOnline

              // ✅ war irgendwann schon mal online (vor diesem Poll)?
              const hadEverBeenOnline = Boolean(everOnline[keyLower])

              const name = String((room as any)?.username ?? keyLower).trim() || keyLower
              const imageUrl = String((room as any)?.image_url ?? '').trim()

              // immer merken: jetzt ist es online
              nextEverOnline[keyLower] = true

              // (B) waiting -> public => "wieder online" (höchste Priorität, damit kein Doppel-Toast)
              const becamePublicFromWaiting = nowShow === 'public' && waiting.has(beforeShow)
              if (becamePublicFromWaiting) {
                if (notificationsOn) {
                  notify.info(name, 'ist wieder online.', {
                    imageUrl,
                    imageAlt: `${name} Vorschau`,
                    durationMs: 5500,
                  })
                }

                if (nowShow) nextShowMap[keyLower] = nowShow
                continue
              }

              // (A/C) watched: offline -> online
              if (watchedSetLower.has(keyLower) && becameOnline) {
                // C: online->offline->online => "wieder online"
                const cameBackFromOffline = hadEverBeenOnline

                // Startup-Spam vermeiden
                if (notificationsOn && !isInitial) {
                  notify.info(
                    name,
                    cameBackFromOffline ? 'ist wieder online.' : 'ist online.',
                    {
                      imageUrl,
                      imageAlt: `${name} Vorschau`,
                      durationMs: 5500,
                    }
                  )
                }
              }

              if (nowShow) nextShowMap[keyLower] = nowShow
            }

            // Presence-Snapshot merken
            const nextOnline: Record<string, true> = {}
            for (const k of Object.keys(nextSnap)) nextOnline[k] = true
            lastCbOnlineByKeyLowerRef.current = nextOnline

            // ✅ "ever online" merken
            everCbOnlineByKeyLowerRef.current = nextEverOnline

            cbOnlineInitDoneRef.current = true
            lastCbShowByKeyLowerRef.current = nextShowMap
          } catch {
            // ignore
          }

          // Online-Keys für Store
          const storeKeys = chaturbateStoreKeysLowerRef.current
          const nextOnlineStore: Record<string, true> = {}
          for (const k of storeKeys || []) {
            const kl = String(k || '').trim().toLowerCase()
            if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
          }

          // Pending Watched Rooms (nur im running Tab)
          if (!recSettingsRef.current.useChaturbateApi) {
            setPendingWatchedRooms([])
          } else if (selectedTabRef.current !== 'running') {
            // optional: nicht leeren
          } else {
            const modelsMap = modelsByKeyRef.current
            const pendingMap = pendingAutoStartByKeyRef.current

            const watchedKeysLower = Array.from(
              new Set(
                Object.values(modelsMap)
                  .filter((m) => Boolean(m?.watching) && String(m?.host ?? '').toLowerCase().includes('chaturbate'))
                  .map((m) => String(m?.modelKey ?? '').trim().toLowerCase())
                  .filter(Boolean)
              )
            )

            const queuedKeysLower = Object.keys(pendingMap || {})
              .map((k) => String(k || '').trim().toLowerCase())
              .filter(Boolean)

            const queuedSetLower = new Set(queuedKeysLower)
            const keysToCheckLower = Array.from(new Set([...watchedKeysLower, ...queuedKeysLower]))

            if (keysToCheckLower.length === 0) {
              setPendingWatchedRooms([])
            } else {
              const nextPending: PendingWatchedRoom[] = []

              for (const keyLower of keysToCheckLower) {
                const room = nextSnap[keyLower]
                if (!room) continue

                const username = String(room?.username ?? '').trim()
                const currentShow = String(room?.current_show ?? 'unknown')

                if (currentShow === 'public' && !queuedSetLower.has(keyLower)) continue

                const canonicalUrl = `https://chaturbate.com/${(username || keyLower).trim()}/`

                nextPending.push({
                  id: keyLower,
                  modelKey: username || keyLower,
                  url: canonicalUrl,
                  currentShow,
                  imageUrl: String((room as any)?.image_url ?? ''),
                })
              }

              nextPending.sort((a, b) => a.modelKey.localeCompare(b.modelKey, undefined, { sensitivity: 'base' }))
              setPendingWatchedRooms(nextPending)
            }
          }

          // queued auto-start
          if (!recSettingsRef.current.useChaturbateApi) return
          if (busyRef.current) return

          const pendingMap = pendingAutoStartByKeyRef.current
          const keys = Object.keys(pendingMap || {})
            .map((k) => String(k || '').toLowerCase())
            .filter(Boolean)

          for (const kLower of keys) {
            const room = nextSnap[kLower]
            if (!room) continue
            if (String(room.current_show ?? '') !== 'public') continue

            const url = pendingMap[kLower]
            if (!url) continue

            // ✅ nicht mehr seriell awaiten, sondern in die Start-Queue
            enqueueStart({ url, silent: true, pendingKeyLower: kLower })
          }

          setLastHeaderUpdateAtMs(Date.now())
        })()
      },
    })

    return () => stop()
  }, [])  

  useEffect(() => {
    // ✅ nur sinnvoll, wenn Chaturbate API aktiv ist
    if (!recSettings.useChaturbateApi) {
      setOnlineModelsCount(0)
      return
    }

    const stop = startChaturbateOnlinePolling({
      // ✅ leer => ALL-mode (durch fetchAllWhenNoModels)
      getModels: () => [],
      getShow: () => ['public', 'private', 'hidden', 'away'],

      // deutlich seltener, weil potentiell groß
      intervalMs: 30000,
      fetchAllWhenNoModels: true,

      onData: (data) => {
        if (!data?.enabled) {
          setOnlineModelsCount(0)
          return
        }

        const total = Number((data as any)?.total ?? 0)
        setOnlineModelsCount(Number.isFinite(total) ? total : 0)

        setLastHeaderUpdateAtMs(Date.now())
      },

      onError: (e) => {
        console.error('[ALL-online poller] error', e)
      },
    })

    return () => stop()
  }, [recSettings.useChaturbateApi])

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

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

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

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

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

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

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

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

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

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

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

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

            <div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
              <div className="relative">
                <label className="sr-only">Source URL</label>
                <input
                  value={sourceUrl}
                  onChange={(e) => setSourceUrl(e.target.value)}
                  placeholder="https://…"
                  className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
                />
              </div>

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

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

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

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

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

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

        <main className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 space-y-6">
          {selectedTab === 'running' ? (
            <Downloads
              jobs={runningJobs}
              modelsByKey={modelsByKey}
              pending={pendingWatchedRooms}
              onOpenPlayer={openPlayer}
              onStopJob={stopJob}
              onToggleFavorite={handleToggleFavorite}
              onToggleLike={handleToggleLike}
              onToggleWatch={handleToggleWatch}
              onAddToDownloads={handleAddToDownloads}
              blurPreviews={Boolean(recSettings.blurPreviews)}
            />
          ) : null}

          {selectedTab === 'finished' ? (
            <FinishedDownloads
              jobs={jobs}
              modelsByKey={modelsByKey}
              doneJobs={doneJobs}
              doneTotal={doneCount}
              page={donePage}
              pageSize={DONE_PAGE_SIZE}
              onPageChange={setDonePage}
              onOpenPlayer={openPlayer}
              onDeleteJob={handleDeleteJobWithUndo}
              onToggleHot={handleToggleHot}
              onToggleFavorite={handleToggleFavorite}
              onToggleLike={handleToggleLike}
              onToggleWatch={handleToggleWatch}
              blurPreviews={Boolean(recSettings.blurPreviews)}
              teaserPlayback={recSettings.teaserPlayback ?? 'hover'}
              teaserAudio={Boolean(recSettings.teaserAudio)}
              assetNonce={assetNonce}
              sortMode={doneSort}
              onSortModeChange={(m) => {
                setDoneSort(m)
                setDonePage(1)
              }}
              loadMode="all"
            />
          ) : null}

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

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

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

        <ModelDetails
          open={Boolean(detailsModelKey)}
          modelKey={detailsModelKey}
          onClose={() => setDetailsModelKey(null)}
          onOpenPlayer={openPlayer}
          runningJobs={runningJobs}
          cookies={cookies}
          blurPreviews={recSettings.blurPreviews}
          onToggleHot={handleToggleHot}
          onDelete={handleDeleteJob}
          onToggleFavorite={handleToggleFavorite}
          onToggleLike={handleToggleLike}
          onToggleWatch={handleToggleWatch}
        />

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