This commit is contained in:
Linrador 2026-01-19 12:56:16 +01:00
parent 7d7387d8bb
commit d909d951a3
29 changed files with 2870 additions and 1452 deletions

View File

@ -23,26 +23,6 @@ type BioContextResp struct {
Bio any `json:"bio,omitempty"` Bio any `json:"bio,omitempty"`
} }
func sanitizeModelKey(s string) string {
s = strings.TrimSpace(strings.TrimPrefix(s, "@"))
// erlaubte chars: a-z A-Z 0-9 _ - .
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z':
return r
case r >= 'A' && r <= 'Z':
return r
case r >= '0' && r <= '9':
return r
case r == '_' || r == '-' || r == '.':
return r
default:
return -1
}
}, s)
return strings.TrimSpace(s)
}
func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) { func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)

View File

@ -443,6 +443,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
"enabled": false, "enabled": false,
"fetchedAt": time.Time{}, "fetchedAt": time.Time{},
"count": 0, "count": 0,
"total": 0,
"lastError": "", "lastError": "",
"rooms": []any{}, "rooms": []any{},
} }
@ -462,6 +463,21 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
cbMu.RUnlock() cbMu.RUnlock()
// ✅ total = Anzahl online rooms (ggf. show-gefiltert), ohne sie auszuliefern
total := 0
if liteByUser != nil {
if len(allowedShow) == 0 {
total = len(liteByUser)
} else {
for _, rm := range liteByUser {
s := strings.ToLower(strings.TrimSpace(rm.CurrentShow))
if allowedShow[s] {
total++
}
}
}
}
// --------------------------- // ---------------------------
// Refresh/Bootstrap-Strategie: // Refresh/Bootstrap-Strategie:
// - Handler blockiert NICHT auf Remote-Fetch (Performance!) // - Handler blockiert NICHT auf Remote-Fetch (Performance!)
@ -570,6 +586,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
"enabled": true, "enabled": true,
"fetchedAt": fetchedAt, "fetchedAt": fetchedAt,
"count": len(outRooms), "count": len(outRooms),
"total": total,
"lastError": lastErr, "lastError": lastErr,
"rooms": outRooms, // ✅ klein & schnell "rooms": outRooms, // ✅ klein & schnell
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-jMGU1_s9.js"></script> <script type="module" crossorigin src="/assets/index-IS5yelG1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ie8TR6qH.css"> <link rel="stylesheet" crossorigin href="/assets/index-ByYRHYVi.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>App</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -13,7 +13,7 @@ import Downloads from './components/ui/Downloads'
import ModelsTab from './components/ui/ModelsTab' import ModelsTab from './components/ui/ModelsTab'
import ProgressBar from './components/ui/ProgressBar' import ProgressBar from './components/ui/ProgressBar'
import ModelDetails from './components/ui/ModelDetails' import ModelDetails from './components/ui/ModelDetails'
import { SignalIcon, HeartIcon, HandThumbUpIcon } from '@heroicons/react/24/solid' import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor' import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify' import { useNotify } from './components/ui/notify'
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
@ -51,6 +51,7 @@ type RecorderSettings = {
blurPreviews?: boolean blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all' teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean teaserAudio?: boolean
lowDiskPauseBelowGB?: number
} }
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = { const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
@ -62,10 +63,11 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
useChaturbateApi: false, useChaturbateApi: false,
useMyFreeCamsWatcher: false, useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false, autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 50, autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false, blurPreviews: false,
teaserPlayback: 'hover', teaserPlayback: 'hover',
teaserAudio: false, teaserAudio: false,
lowDiskPauseBelowGB: 3000,
} }
type StoredModel = { type StoredModel = {
@ -106,6 +108,7 @@ type ChaturbateOnlineRoom = {
type ChaturbateOnlineResponse = { type ChaturbateOnlineResponse = {
enabled: boolean enabled: boolean
rooms: ChaturbateOnlineRoom[] rooms: ChaturbateOnlineRoom[]
total?: number
} }
function normalizeHttpUrl(raw: string): string | null { function normalizeHttpUrl(raw: string): string | null {
@ -222,6 +225,8 @@ export default function App() {
const [doneCount, setDoneCount] = useState<number>(0) const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0) const [modelsCount, setModelsCount] = useState(0)
const [onlineModelsCount, setOnlineModelsCount] = useState(0)
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now()) const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now()) const [nowMs, setNowMs] = useState<number>(() => Date.now())
useEffect(() => { useEffect(() => {
@ -413,9 +418,6 @@ export default function App() {
const pendingStartUrlRef = useRef<string | null>(null) const pendingStartUrlRef = useRef<string | null>(null)
const lastClipboardUrlRef = useRef<string>('') const lastClipboardUrlRef = useRef<string>('')
// ✅ Online-Status für Models aus dem Model-Store
const [onlineStoreKeysLower, setOnlineStoreKeysLower] = useState<Record<string, true>>({})
// ✅ Zentraler Snapshot: username(lower) -> room // ✅ Zentraler Snapshot: username(lower) -> room
const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({}) const [cbOnlineByKeyLower, setCbOnlineByKeyLower] = useState<Record<string, ChaturbateOnlineRoom>>({})
const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({}) const cbOnlineByKeyLowerRef = useRef<Record<string, ChaturbateOnlineRoom>>({})
@ -569,7 +571,7 @@ export default function App() {
const onFocus = () => void load() const onFocus = () => void load()
window.addEventListener('recorder-settings-updated', onUpdated as EventListener) window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
window.addEventListener('hover', onFocus) window.addEventListener('focus', onFocus)
document.addEventListener('visibilitychange', onFocus) document.addEventListener('visibilitychange', onFocus)
load() load()
@ -577,7 +579,7 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener) window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('hover', onFocus) window.removeEventListener('focus', onFocus)
document.removeEventListener('visibilitychange', onFocus) document.removeEventListener('visibilitychange', onFocus)
} }
}, []) }, [])
@ -618,15 +620,20 @@ export default function App() {
const runningJobs = jobs.filter((j) => j.status === 'running') const runningJobs = jobs.filter((j) => j.status === 'running')
const onlineModelsCount = useMemo(() => { // ✅ Anzahl Watched Models (aus Store), die online sind
const onlineWatchedModelsCount = useMemo(() => {
let c = 0 let c = 0
for (const m of Object.values(modelsByKey)) { for (const m of Object.values(modelsByKey)) {
if (!m?.watching) continue
if (!isChaturbateStoreModel(m)) continue
const k = lower(String(m?.modelKey ?? '')) const k = lower(String(m?.modelKey ?? ''))
if (!k) continue if (!k) continue
if (onlineStoreKeysLower[k]) c++
if (cbOnlineByKeyLower[k]) c++
} }
return c return c
}, [modelsByKey, onlineStoreKeysLower]) }, [modelsByKey, cbOnlineByKeyLower, isChaturbateStoreModel])
const { onlineFavCount, onlineLikedCount } = useMemo(() => { const { onlineFavCount, onlineLikedCount } = useMemo(() => {
let fav = 0 let fav = 0
@ -635,14 +642,14 @@ export default function App() {
for (const m of Object.values(modelsByKey)) { for (const m of Object.values(modelsByKey)) {
const k = lower(String(m?.modelKey ?? '')) const k = lower(String(m?.modelKey ?? ''))
if (!k) continue if (!k) continue
if (!onlineStoreKeysLower[k]) continue if (!cbOnlineByKeyLower[k]) continue
if (m?.favorite) fav++ if (m?.favorite) fav++
if (m?.liked === true) liked++ if (m?.liked === true) liked++
} }
return { onlineFavCount: fav, onlineLikedCount: liked } return { onlineFavCount: fav, onlineLikedCount: liked }
}, [modelsByKey, onlineStoreKeysLower]) }, [modelsByKey, cbOnlineByKeyLower])
const tabs: TabItem[] = [ const tabs: TabItem[] = [
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length }, { id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
@ -706,18 +713,28 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded]) }, [cookies, cookiesLoaded])
// done meta polling (unverändert) // ✅ done count polling über /api/record/done (kein /done/meta mehr)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
let t: number | undefined let t: number | undefined
const loadDoneMeta = async () => { const loadDoneCount = async () => {
try { try {
const res = await fetch('/api/record/done/meta', { cache: 'no-store' }) // pageSize=1 => minimaler Payload, zählt trotzdem korrekt
const res = await fetch(
`/api/record/done?page=1&pageSize=1&withCount=1&sort=${encodeURIComponent(doneSort)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return if (!res.ok) return
const meta = (await res.json()) as { count?: number }
const data = await res.json().catch(() => null)
const countRaw =
Number(data?.count ?? data?.totalCount ?? 0)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
if (!cancelled) { if (!cancelled) {
setDoneCount(meta.count ?? 0) setDoneCount(count)
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(Date.now())
} }
} catch { } catch {
@ -725,24 +742,24 @@ export default function App() {
} finally { } finally {
if (!cancelled) { if (!cancelled) {
const ms = document.hidden ? 60_000 : 30_000 const ms = document.hidden ? 60_000 : 30_000
t = window.setTimeout(loadDoneMeta, ms) t = window.setTimeout(loadDoneCount, ms)
} }
} }
} }
const onVis = () => { const onVis = () => {
if (!document.hidden) void loadDoneMeta() if (!document.hidden) void loadDoneCount()
} }
document.addEventListener('visibilitychange', onVis) document.addEventListener('visibilitychange', onVis)
void loadDoneMeta() void loadDoneCount()
return () => { return () => {
cancelled = true cancelled = true
if (t) window.clearTimeout(t) if (t) window.clearTimeout(t)
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
} }
}, []) }, [doneSort])
useEffect(() => { useEffect(() => {
const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE)) const maxPage = Math.max(1, Math.ceil(doneCount / DONE_PAGE_SIZE))
@ -834,29 +851,62 @@ export default function App() {
if (selectedTab !== 'finished') return if (selectedTab !== 'finished') return
let cancelled = false let cancelled = false
let inFlight = false const inFlightRef = { current: false }
const ac = new AbortController()
const loadDone = async () => { const loadDone = async () => {
if (cancelled || inFlight) return if (cancelled || inFlightRef.current) return
inFlight = true inFlightRef.current = true
try { try {
const list = await apiJSON<RecordJob[]>( const res = await fetch(
`/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`, `/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
{ cache: 'no-store' as any } `&sort=${encodeURIComponent(doneSort)}` +
`&withCount=1`,
{ cache: 'no-store' as any, signal: ac.signal }
) )
if (!cancelled) setDoneJobs(Array.isArray(list) ? list : [])
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json().catch(() => null)
// akzeptiere beide Formen:
// A) { count, items }
// B) doneListResponse { totalCount, items }
// C) Legacy array
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const countRaw =
typeof data?.count === 'number'
? data.count
: typeof data?.totalCount === 'number'
? data.totalCount
: items.length
if (!cancelled) {
setDoneJobs(items)
setDoneCount(Number.isFinite(countRaw) ? countRaw : items.length)
}
} catch { } catch {
if (!cancelled) setDoneJobs([]) if (!cancelled) {
setDoneJobs([])
setDoneCount(0)
}
} finally { } finally {
inFlight = false inFlightRef.current = false
} }
} }
loadDone() void loadDone()
const baseMs = 20000 const baseMs = 20000
const tickMs = document.hidden ? 60000 : baseMs const t = window.setInterval(() => {
const t = window.setInterval(loadDone, tickMs) if (!document.hidden) void loadDone()
}, baseMs)
const onVis = () => { const onVis = () => {
if (!document.hidden) void loadDone() if (!document.hidden) void loadDone()
@ -865,6 +915,7 @@ export default function App() {
return () => { return () => {
cancelled = true cancelled = true
ac.abort()
window.clearInterval(t) window.clearInterval(t)
document.removeEventListener('visibilitychange', onVis) document.removeEventListener('visibilitychange', onVis)
} }
@ -873,21 +924,36 @@ export default function App() {
const refreshDoneNow = useCallback( const refreshDoneNow = useCallback(
async (preferPage?: number) => { async (preferPage?: number) => {
try { try {
const meta = await apiJSON<{ count?: number }>('/api/record/done/meta', { cache: 'no-store' as any }) const wanted = typeof preferPage === 'number' ? preferPage : donePage
const countRaw = typeof meta?.count === 'number' ? meta.count : 0
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 const data = await apiJSON<any>(
`/api/record/done?page=${wanted}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : items.length
setDoneCount(count) setDoneCount(count)
const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE)) const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE))
const wanted = typeof preferPage === 'number' ? preferPage : donePage
const target = Math.min(Math.max(1, wanted), maxPage) const target = Math.min(Math.max(1, wanted), maxPage)
if (target !== donePage) setDonePage(target) if (target !== donePage) setDonePage(target)
const list = await apiJSON<RecordJob[]>( // wenn target anders ist, optional nochmal mit target laden:
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(doneSort)}`, if (target === wanted) {
{ cache: 'no-store' as any } setDoneJobs(items)
) } else {
setDoneJobs(Array.isArray(list) ? list : []) const data2 = await apiJSON<any>(
`/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=${encodeURIComponent(doneSort)}&withCount=1`,
{ cache: 'no-store' as any }
)
const items2 = Array.isArray(data2?.items) ? (data2.items as RecordJob[]) : []
setDoneJobs(items2)
}
} catch { } catch {
// ignore // ignore
} }
@ -967,7 +1033,7 @@ export default function App() {
notify.error('Löschen fehlgeschlagen', e?.message ?? String(e)) notify.error('Löschen fehlgeschlagen', e?.message ?? String(e))
return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr return // ✅ wichtig: kein throw mehr -> keine Popup-Alerts mehr
} }
}, []) }, [notify])
const handleKeepJob = useCallback( const handleKeepJob = useCallback(
async (job: RecordJob) => { async (job: RecordJob) => {
@ -993,7 +1059,7 @@ export default function App() {
return return
} }
}, },
[selectedTab, refreshDoneNow] [selectedTab, refreshDoneNow, notify]
) )
const handleToggleHot = useCallback(async (job: RecordJob) => { const handleToggleHot = useCallback(async (job: RecordJob) => {
@ -1682,7 +1748,7 @@ export default function App() {
void startUrl(pending) void startUrl(pending)
}, [busy, autoStartEnabled, startUrl]) }, [busy, autoStartEnabled, startUrl])
useEffect(() => { useEffect(() => {
const stop = startChaturbateOnlinePolling({ const stop = startChaturbateOnlinePolling({
getModels: () => { getModels: () => {
if (!recSettingsRef.current.useChaturbateApi) return [] if (!recSettingsRef.current.useChaturbateApi) return []
@ -1713,7 +1779,6 @@ export default function App() {
if (!data?.enabled) { if (!data?.enabled) {
setCbOnlineByKeyLower({}) setCbOnlineByKeyLower({})
cbOnlineByKeyLowerRef.current = {} cbOnlineByKeyLowerRef.current = {}
setOnlineStoreKeysLower({})
setPendingWatchedRooms([]) setPendingWatchedRooms([])
setLastHeaderUpdateAtMs(Date.now()) setLastHeaderUpdateAtMs(Date.now())
return return
@ -1735,7 +1800,6 @@ export default function App() {
const kl = String(k || '').trim().toLowerCase() const kl = String(k || '').trim().toLowerCase()
if (kl && nextSnap[kl]) nextOnlineStore[kl] = true if (kl && nextSnap[kl]) nextOnlineStore[kl] = true
} }
setOnlineStoreKeysLower(nextOnlineStore)
// Pending Watched Rooms (nur im running Tab) // Pending Watched Rooms (nur im running Tab)
if (!recSettingsRef.current.useChaturbateApi) { if (!recSettingsRef.current.useChaturbateApi) {
@ -1829,6 +1893,42 @@ export default function App() {
return () => stop() 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])
return ( return (
<div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100"> <div className="min-h-screen bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden"> <div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
@ -1857,6 +1957,14 @@ export default function App() {
<span className="tabular-nums">{onlineModelsCount}</span> <span className="tabular-nums">{onlineModelsCount}</span>
</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 <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" 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" title="Fav online"
@ -2002,7 +2110,6 @@ export default function App() {
setDoneSort(m) setDoneSort(m)
setDonePage(1) setDonePage(1)
}} }}
onRefreshDone={refreshDoneNow}
/> />
) : null} ) : null}
@ -2026,10 +2133,21 @@ export default function App() {
}} }}
/> />
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
/>
{playerJob ? ( {playerJob ? (
<Player <Player
job={playerJob} job={playerJob}
modelKey={playerModelKey ?? undefined} modelKey={playerModelKey ?? undefined}
modelsByKey={modelsByKey}
expanded={playerExpanded} expanded={playerExpanded}
onToggleExpand={() => setPlayerExpanded((s) => !s)} onToggleExpand={() => setPlayerExpanded((s) => !s)}
onClose={() => setPlayerJob(null)} onClose={() => setPlayerJob(null)}
@ -2046,16 +2164,6 @@ export default function App() {
onToggleWatch={handleToggleWatch} onToggleWatch={handleToggleWatch}
/> />
) : null} ) : null}
<ModelDetails
open={Boolean(detailsModelKey)}
modelKey={detailsModelKey}
onClose={() => setDetailsModelKey(null)}
onOpenPlayer={openPlayer}
runningJobs={runningJobs}
cookies={cookies}
blurPreviews={recSettings.blurPreviews}
/>
</div> </div>
</div> </div>
) )

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\Downloads.tsx // frontend\src\components\ui\Downloads.tsx
'use client' 'use client'
import { useMemo, useState, useCallback, useEffect } from 'react' import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import Table, { type Column } from './Table' import Table, { type Column } from './Table'
import Card from './Card' import Card from './Card'
import Button from './Button' import Button from './Button'
@ -547,12 +547,15 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const [stopAllBusy, setStopAllBusy] = useState(false) const [stopAllBusy, setStopAllBusy] = useState(false)
const [watchedPaused, setWatchedPaused] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false)
const watchedPausedRef = useRef<boolean | null>(null)
const [watchedBusy, setWatchedBusy] = useState(false) const [watchedBusy, setWatchedBusy] = useState(false)
const refreshWatchedState = useCallback(async () => { const refreshWatchedState = useCallback(async () => {
try { try {
const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any }) const s = await apiJSON<AutostartState>('/api/autostart/state', { cache: 'no-store' as any })
setWatchedPaused(Boolean(s?.paused)) const next = Boolean(s?.paused)
watchedPausedRef.current = next
setWatchedPaused(next)
} catch { } catch {
// wenn Endpoint (noch) nicht da ist: nichts kaputt machen // wenn Endpoint (noch) nicht da ist: nichts kaputt machen
} }
@ -566,7 +569,12 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const unsub = subscribeSSE<AutostartState>( const unsub = subscribeSSE<AutostartState>(
'/api/autostart/state/stream', '/api/autostart/state/stream',
'autostart', 'autostart',
(data) => setWatchedPaused(Boolean((data as any)?.paused)) (data) => {
const next = Boolean((data as any)?.paused)
if (watchedPausedRef.current === next) return
watchedPausedRef.current = next
setWatchedPaused(next)
}
) )
return () => { return () => {
@ -656,7 +664,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
useEffect(() => { useEffect(() => {
if (!hasActive) return if (!hasActive) return
const t = window.setInterval(() => setNowMs(Date.now()), 1000) const t = window.setInterval(() => setNowMs(Date.now()), 15000)
return () => window.clearInterval(t) return () => window.clearInterval(t)
}, [hasActive]) }, [hasActive])
@ -738,15 +746,22 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
const j = r.job const j = r.job
const f = baseName(j.output || '') const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output) const name = modelNameFromOutput(j.output)
const phase = String((j as any).phase ?? '').trim()
const rawStatus = String(j.status ?? '').toLowerCase()
// Final "stopped" sauber erkennen (inkl. UI-Stop)
const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopRequested = Boolean(stopRequestedIds[j.id])
const stopInitiated = Boolean(stopInitiatedIds[j.id]) const stopInitiated = Boolean(stopInitiatedIds[j.id])
const rawStatus = String(j.status ?? '').toLowerCase()
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt)) const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested // ✅ Status-Text neben dem Modelname: NUR Job-Status
const statusText = rawStatus || 'unknown' // (keine phase wie assets/moving/remuxing)
const statusText = isStoppedFinal ? 'stopped' : (rawStatus || 'unknown')
// Optional: "Stoppe…" rein UI-seitig anzeigen, aber ohne phase
const showStoppingUI = !isStoppedFinal && isStopRequested
const badgeText = showStoppingUI ? 'stopping' : statusText
return ( return (
<> <>
@ -757,20 +772,21 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
// Status-Farben rawStatus === 'running'
isStopping ? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25' : isStoppedFinal
: j.status === 'finished' ? 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25'
? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25' : rawStatus === 'failed'
: j.status === 'failed'
? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25' ? 'bg-red-500/15 text-red-900 ring-red-500/30 dark:bg-red-400/10 dark:text-red-200 dark:ring-red-400/25'
: isStoppedFinal : rawStatus === 'finished'
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25' ? 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10', : showStoppingUI
? 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25'
: 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')} ].join(' ')}
title={statusText} title={badgeText}
> >
{statusText} {badgeText}
</span> </span>
</div> </div>
<span className="block max-w-[220px] truncate" title={j.output}> <span className="block max-w-[220px] truncate" title={j.output}>
@ -984,70 +1000,70 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
{(hasAnyPending || hasJobs) ? ( {/* ✅ Toolbar immer sichtbar (auch wenn keine Jobs/Pending existieren) */}
<> <div className="sticky top-[56px] z-20">
{/* Toolbar (sticky) wie FinishedDownloads */} <div
<div className="sticky top-[56px] z-20"> className="
<div rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
className=" backdrop-blur supports-[backdrop-filter]:bg-white/60
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
backdrop-blur supports-[backdrop-filter]:bg-white/60 "
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40 >
" <div className="flex items-center justify-between gap-2 p-3">
> {/* Title + Count */}
<div className="flex items-center justify-between gap-2 p-3"> <div className="min-w-0 flex items-center gap-2">
{/* Title + Count */} <div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
<div className="min-w-0 flex items-center gap-2"> Downloads
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
</span>
</div>
{/* Actions + View */}
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div> </div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
</span>
</div>
{/* Actions */}
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={watchedPaused ? 'Autostart fortsetzen' : 'Autostart pausieren'}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div> </div>
</div> </div>
</div>
</div>
{/* Content */} {/* ✅ Content abhängig von Jobs/Pending */}
{(hasAnyPending || hasJobs) ? (
<>
{/* Mobile: Cards */} {/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden"> <div className="mt-3 grid gap-4 sm:hidden">
{rows.map((r) => ( {rows.map((r) => (
@ -1085,9 +1101,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
/> />
</div> </div>
</> </>
) : null} ) : (
{!hasAnyPending && !hasJobs ? (
<Card grayBody> <Card grayBody>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10"> <div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
@ -1103,7 +1117,7 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
</div> </div>
</div> </div>
</Card> </Card>
) : null} )}
</div> </div>
) )
} }

View File

@ -9,7 +9,14 @@ import Card from './Card'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import FinishedVideoPreview from './FinishedVideoPreview' import FinishedVideoPreview from './FinishedVideoPreview'
import ButtonGroup from './ButtonGroup' import ButtonGroup from './ButtonGroup'
import { TableCellsIcon, RectangleStackIcon, Squares2X2Icon } from '@heroicons/react/24/outline' import {
TableCellsIcon,
RectangleStackIcon,
Squares2X2Icon,
AdjustmentsHorizontalIcon,
ChevronDownIcon,
ChevronUpIcon,
} from '@heroicons/react/24/outline'
import { type SwipeCardHandle } from './SwipeCard' import { type SwipeCardHandle } from './SwipeCard'
import { flushSync } from 'react-dom' import { flushSync } from 'react-dom'
import FinishedDownloadsCardsView from './FinishedDownloadsCardsView' import FinishedDownloadsCardsView from './FinishedDownloadsCardsView'
@ -22,7 +29,8 @@ import RecordJobActions from './RecordJobActions'
import Button from './Button' import Button from './Button'
import { useNotify } from './notify' import { useNotify } from './notify'
import LazyMount from './LazyMount' import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
import Switch from './Switch'
type SortMode = type SortMode =
| 'completed_desc' | 'completed_desc'
@ -55,13 +63,13 @@ type Props = {
page: number page: number
pageSize: number pageSize: number
onPageChange: (page: number) => void onPageChange: (page: number) => void
onRefreshDone?: (preferPage?: number) => void | Promise<void>
assetNonce?: number assetNonce?: number
sortMode: SortMode sortMode: SortMode
onSortModeChange: (m: SortMode) => void onSortModeChange: (m: SortMode) => void
} }
const norm = (p: string) => (p || '').replaceAll('\\', '/').trim() const norm = (p: string) => (p || '').replaceAll('\\', '/')
const baseName = (p: string) => { const baseName = (p: string) => {
const n = norm(p) const n = norm(p)
const parts = n.split('/') const parts = n.split('/')
@ -118,8 +126,6 @@ const httpCodeFromError = (err?: string) => {
return m ? `HTTP ${m[1]}` : null return m ? `HTTP ${m[1]}` : null
} }
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const modelNameFromOutput = (output?: string) => { const modelNameFromOutput = (output?: string) => {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
@ -197,7 +203,6 @@ export default function FinishedDownloads({
page, page,
pageSize, pageSize,
onPageChange, onPageChange,
onRefreshDone,
assetNonce, assetNonce,
sortMode, sortMode,
onSortModeChange, onSortModeChange,
@ -241,8 +246,13 @@ export default function FinishedDownloads({
type ViewMode = 'table' | 'cards' | 'gallery' type ViewMode = 'table' | 'cards' | 'gallery'
const VIEW_KEY = 'finishedDownloads_view' const VIEW_KEY = 'finishedDownloads_view'
const KEEP_KEY = 'finishedDownloads_includeKeep_v2'
const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1'
const [view, setView] = React.useState<ViewMode>('table') const [view, setView] = React.useState<ViewMode>('table')
const [includeKeep, setIncludeKeep] = React.useState(false)
const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false)
const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map()) const swipeRefs = React.useRef<Map<string, SwipeCardHandle>>(new Map())
@ -287,18 +297,23 @@ export default function FinishedDownloads({
const fetchAllDoneJobs = useCallback( const fetchAllDoneJobs = useCallback(
async (signal?: AbortSignal) => { async (signal?: AbortSignal) => {
const res = await fetch(`/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}`, { const res = await fetch(
cache: 'no-store' as any, `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
signal, {
}) cache: 'no-store' as any,
signal,
}
)
if (!res.ok) return if (!res.ok) return
const list = await res.json().catch(() => []) const data = await res.json().catch(() => null)
const arr = Array.isArray(list) ? (list as RecordJob[]) : [] const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
setOverrideDoneJobs(arr) const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneTotal(arr.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
}, },
[sortMode] [sortMode, includeKeep]
) )
const clearTagFilter = useCallback(() => setTagFilter([]), []) const clearTagFilter = useCallback(() => setTagFilter([]), [])
@ -338,16 +353,18 @@ export default function FinishedDownloads({
;(async () => { ;(async () => {
try { try {
// 1) META holen (count), damit Pagination stimmt // 1) Liste + count in EINEM Request holen (mitCount), damit Pagination stimmt
const metaRes = await fetch('/api/record/done/meta', { const listRes = await fetch(
cache: 'no-store' as any, `/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${includeKeep ? '&includeKeep=1' : ''}`,
signal: ac.signal, { cache: 'no-store' as any, signal: ac.signal }
}) )
if (metaRes.ok) { if (listRes.ok) {
const meta = await metaRes.json().catch(() => null) const data = await listRes.json().catch(() => null)
const count = Number(meta?.count ?? 0) const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
if (Number.isFinite(count) && count >= 0) { if (Number.isFinite(count) && count >= 0) {
setOverrideDoneTotal(count) setOverrideDoneTotal(count)
@ -359,24 +376,13 @@ export default function FinishedDownloads({
} }
} }
} }
// 2) LISTE für aktuelle Seite holen
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (listRes.ok) {
const list = await listRes.json().catch(() => [])
setOverrideDoneJobs(Array.isArray(list) ? list : [])
}
} catch { } catch {
// Abort / Fehler ignorieren // Abort / Fehler ignorieren
} }
})() })()
return () => ac.abort() return () => ac.abort()
}, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs]) }, [refillTick, page, pageSize, onPageChange, sortMode, globalFilterActive, fetchAllDoneJobs, includeKeep])
useEffect(() => { useEffect(() => {
// Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1) // Wenn Filter aktiv: Overrides behalten (wir arbeiten mit all=1)
@ -386,6 +392,42 @@ export default function FinishedDownloads({
setOverrideDoneTotal(null) setOverrideDoneTotal(null)
}, [doneJobs, doneTotal, globalFilterActive]) }, [doneJobs, doneTotal, globalFilterActive])
useEffect(() => {
if (!includeKeep) {
// zurück auf "nur /done/" (Props)
if (!globalFilterActive) {
setOverrideDoneJobs(null)
setOverrideDoneTotal(null)
}
return
}
// includeKeep = true:
// - wenn Filter aktiv -> fetchAllDoneJobs macht das bereits (mit includeKeep)
if (globalFilterActive) return
const ac = new AbortController()
;(async () => {
try {
const res = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1&includeKeep=1`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? data?.totalCount ?? items.length)
setOverrideDoneJobs(items)
setOverrideDoneTotal(Number.isFinite(count) ? count : items.length)
} catch {}
})()
return () => ac.abort()
}, [includeKeep, globalFilterActive, page, pageSize, sortMode])
useEffect(() => { useEffect(() => {
try { try {
const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null
@ -406,6 +448,36 @@ export default function FinishedDownloads({
} catch {} } catch {}
}, [view]) }, [view])
useEffect(() => {
try {
const raw = localStorage.getItem(KEEP_KEY)
setIncludeKeep(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setIncludeKeep(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(KEEP_KEY, includeKeep ? '1' : '0')
} catch {}
}, [includeKeep])
useEffect(() => {
try {
const raw = localStorage.getItem(MOBILE_OPTS_KEY)
setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes')
} catch {
setMobileOptionsOpen(false)
}
}, [])
useEffect(() => {
try {
localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0')
} catch {}
}, [mobileOptionsOpen])
// 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden) // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden)
const [durations, setDurations] = React.useState<Record<string, number>>({}) const [durations, setDurations] = React.useState<Record<string, number>>({})
@ -486,12 +558,9 @@ export default function FinishedDownloads({
// ✅ wichtig: Seite sofort neu laden -> Item rückt nach // ✅ wichtig: Seite sofort neu laden -> Item rückt nach
queueRefill() queueRefill()
// optional: Parent sync (kann bleiben, muss aber nicht)
void onRefreshDone?.(page)
}, 320) }, 320)
}, },
[markDeleted, markRemoving, queueRefill, onRefreshDone, page] [markDeleted, markRemoving, queueRefill]
) )
const releasePlayingFile = useCallback( const releasePlayingFile = useCallback(
@ -524,9 +593,6 @@ export default function FinishedDownloads({
if (onDeleteJob) { if (onDeleteJob) {
await onDeleteJob(job) await onDeleteJob(job)
// ✅ nach erfolgreichem Delete die Page nachziehen
queueRefill()
return true return true
} }
@ -546,7 +612,7 @@ export default function FinishedDownloads({
markDeleting(key, false) markDeleting(key, false)
} }
}, },
[deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill, notify] [deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, notify]
) )
const keepVideo = useCallback( const keepVideo = useCallback(
@ -707,7 +773,6 @@ export default function FinishedDownloads({
if (view === 'cards') { if (view === 'cards') {
window.setTimeout(() => { window.setTimeout(() => {
markDeleted(key) markDeleted(key)
void onRefreshDone?.(page) // ✅ HIER dazu
}, 320) }, 320)
} else { } else {
animateRemove(key) animateRemove(key)
@ -722,13 +787,12 @@ export default function FinishedDownloads({
} else if (detail.phase === 'success') { } else if (detail.phase === 'success') {
markDeleting(key, false) markDeleting(key, false)
queueRefill() queueRefill()
void onRefreshDone?.(page)
} }
} }
window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener)
return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener)
}, [animateRemove, markDeleting, markDeleted, view, onRefreshDone, page, queueRefill]) }, [animateRemove, markDeleting, markDeleted, view, queueRefill])
const visibleRows = useMemo(() => { const visibleRows = useMemo(() => {
const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j)))
@ -949,16 +1013,16 @@ export default function FinishedDownloads({
sortable: true, sortable: true,
sortValue: (j) => { sortValue: (j) => {
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ') const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim() return `${model} ${isHot ? 'HOT' : ''} ${file}`.trim()
}, },
cell: (j) => { cell: (j) => {
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ') const isHot = isHotName(fileRaw)
const model = modelNameFromOutput(j.output)
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
const model = modelNameFromOutput(j.output)
const modelKey = lower(modelNameFromOutput(j.output)) const modelKey = lower(modelNameFromOutput(j.output))
const tags = parseTags(modelsByKey[modelKey]?.tags) const tags = parseTags(modelsByKey[modelKey]?.tags)
const show = tags.slice(0, 6) const show = tags.slice(0, 6)
@ -1113,7 +1177,7 @@ export default function FinishedDownloads({
const k = keyFor(j) const k = keyFor(j)
const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ') const isHot = isHotName(fileRaw)
const modelKey = lower(modelNameFromOutput(j.output)) const modelKey = lower(modelNameFromOutput(j.output))
const flags = modelsByKey[modelKey] const flags = modelsByKey[modelKey]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
@ -1246,6 +1310,22 @@ export default function FinishedDownloads({
</div> </div>
)} )}
{/* Keep toggle (Desktop) */}
<div className="hidden sm:flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-200">Behaltene Downloads anzeigen</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="short"
className=""
/>
</div>
{/* Desktop: Suche neben Sort */} {/* Desktop: Suche neben Sort */}
<div className="hidden sm:flex items-center gap-2 min-w-0"> <div className="hidden sm:flex items-center gap-2 min-w-0">
<input <input
@ -1265,6 +1345,24 @@ export default function FinishedDownloads({
) : null} ) : null}
</div> </div>
{/* Mobile: Optionen ein/ausklappen */}
<button
type="button"
className="sm:hidden inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-2.5 text-xs font-semibold text-gray-900 shadow-sm
hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:hover:bg-white/10"
onClick={() => setMobileOptionsOpen((v) => !v)}
aria-expanded={mobileOptionsOpen}
aria-controls="finished-mobile-options"
>
<AdjustmentsHorizontalIcon className="size-4" />
Optionen
{mobileOptionsOpen ? (
<ChevronUpIcon className="size-4 opacity-80" />
) : (
<ChevronDownIcon className="size-4 opacity-80" />
)}
</button>
{/* Views */} {/* Views */}
<ButtonGroup <ButtonGroup
value={view} value={view}
@ -1295,24 +1393,54 @@ export default function FinishedDownloads({
</div> </div>
</div> </div>
{/* Mobile Suche */} {/* Mobile Optionen (einklappbar): Suche + Keep + Sort */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2"> <div
<div className="flex items-center gap-2"> id="finished-mobile-options"
<input className={[
value={searchQuery} 'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
onChange={(e) => setSearchQuery(e.target.value)} mobileOptionsOpen ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0',
placeholder="Suchen…" ].join(' ')}
className=" >
w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500 {/* Mobile Suche */}
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark] <div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
" <div className="flex items-center gap-2">
/> <input
{(searchQuery || '').trim() !== '' ? ( value={searchQuery}
<Button size="sm" variant="soft" onClick={clearSearch}> onChange={(e) => setSearchQuery(e.target.value)}
Clear placeholder="Suchen…"
</Button> className="
) : null} w-full h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
"
/>
{(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}>
Clear
</Button>
) : null}
</div>
</div>
{/* Mobile: Keep Toggle */}
<div className="sm:hidden border-t border-gray-200/60 dark:border-white/10 p-3 pt-2">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
Behaltene Downloads anzeigen
</span>
<Switch
checked={includeKeep}
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"
/>
</div>
</div> </div>
</div> </div>
@ -1444,7 +1572,6 @@ export default function FinishedDownloads({
swipeRefs={swipeRefs} swipeRefs={swipeRefs}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}
stripHotPrefix={stripHotPrefix}
modelNameFromOutput={modelNameFromOutput} modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf} runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf} sizeBytesOf={sizeBytesOf}
@ -1467,6 +1594,7 @@ export default function FinishedDownloads({
activeTagSet={activeTagSet} activeTagSet={activeTagSet}
onToggleTagFilter={toggleTagFilter} onToggleTagFilter={toggleTagFilter}
onHoverPreviewKeyChange={setHoverTeaserKey} onHoverPreviewKeyChange={setHoverTeaserKey}
assetNonce={assetNonce ?? 0}
/> />
)} )}
@ -1507,7 +1635,6 @@ export default function FinishedDownloads({
hoverTeaserKey={hoverTeaserKey} hoverTeaserKey={hoverTeaserKey}
keyFor={keyFor} keyFor={keyFor}
baseName={baseName} baseName={baseName}
stripHotPrefix={stripHotPrefix}
modelNameFromOutput={modelNameFromOutput} modelNameFromOutput={modelNameFromOutput}
runtimeOf={runtimeOf} runtimeOf={runtimeOf}
sizeBytesOf={sizeBytesOf} sizeBytesOf={sizeBytesOf}

View File

@ -15,7 +15,7 @@ import {
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount' import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
@ -35,6 +35,7 @@ type Props = {
teaserKey: string | null teaserKey: string | null
inlinePlay: InlinePlayState inlinePlay: InlinePlayState
setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>> setInlinePlay: React.Dispatch<React.SetStateAction<InlinePlayState>>
deletingKeys: Set<string> deletingKeys: Set<string>
keepingKeys: Set<string> keepingKeys: Set<string>
@ -42,10 +43,11 @@ type Props = {
swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>> swipeRefs: React.MutableRefObject<Map<string, SwipeCardHandle>>
assetNonce?: number
// helpers // helpers
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
baseName: (p: string) => string baseName: (p: string) => string
stripHotPrefix: (s: string) => string
modelNameFromOutput: (output?: string) => string modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null sizeBytesOf: (job: RecordJob) => number | null
@ -104,7 +106,6 @@ export default function FinishedDownloadsCardsView({
teaserAudio, teaserAudio,
hoverTeaserKey, hoverTeaserKey,
blurPreviews, blurPreviews,
durations,
teaserKey, teaserKey,
inlinePlay, inlinePlay,
setInlinePlay, setInlinePlay,
@ -114,10 +115,10 @@ export default function FinishedDownloadsCardsView({
removingKeys, removingKeys,
swipeRefs, swipeRefs,
assetNonce,
keyFor, keyFor,
baseName, baseName,
stripHotPrefix,
modelNameFromOutput, modelNameFromOutput,
runtimeOf, runtimeOf,
sizeBytesOf, sizeBytesOf,
@ -131,8 +132,6 @@ export default function FinishedDownloadsCardsView({
tryAutoplayInline, tryAutoplayInline,
registerTeaserHost, registerTeaserHost,
handleDuration,
deleteVideo, deleteVideo,
keepVideo, keepVideo,
@ -196,7 +195,7 @@ export default function FinishedDownloadsCardsView({
const model = modelNameFromOutput(j.output) const model = modelNameFromOutput(j.output)
const fileRaw = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = fileRaw.startsWith('HOT ') const isHot = isHotName(fileRaw)
const flags = modelsByKey[lower(model)] const flags = modelsByKey[lower(model)]
const isFav = Boolean(flags?.favorite) const isFav = Boolean(flags?.favorite)
const isLiked = flags?.liked === true const isLiked = flags?.liked === true
@ -280,6 +279,7 @@ export default function FinishedDownloadsCardsView({
inlineLoop={false} inlineLoop={false}
muted={previewMuted} muted={previewMuted}
popoverMuted={previewMuted} popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/> />
</LazyMount> </LazyMount>

View File

@ -11,6 +11,8 @@ import {
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount' import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
type Props = { type Props = {
rows: RecordJob[] rows: RecordJob[]
@ -26,7 +28,6 @@ type Props = {
keyFor: (j: RecordJob) => string keyFor: (j: RecordJob) => string
baseName: (p: string) => string baseName: (p: string) => string
stripHotPrefix: (s: string) => string
modelNameFromOutput: (output?: string) => string modelNameFromOutput: (output?: string) => string
runtimeOf: (job: RecordJob) => string runtimeOf: (job: RecordJob) => string
sizeBytesOf: (job: RecordJob) => number | null sizeBytesOf: (job: RecordJob) => number | null
@ -67,7 +68,6 @@ export default function FinishedDownloadsGalleryView({
keyFor, keyFor,
baseName, baseName,
stripHotPrefix,
modelNameFromOutput, modelNameFromOutput,
runtimeOf, runtimeOf,
sizeBytesOf, sizeBytesOf,
@ -97,6 +97,23 @@ export default function FinishedDownloadsGalleryView({
const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null) const [openTagsKey, setOpenTagsKey] = React.useState<string | null>(null)
const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null) const tagsPopoverRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Teaser-Observer nur aktiv, wenn Preview überhaupt "laufen" soll
const shouldObserveTeasers = teaserPlayback === 'hover' || teaserPlayback === 'all'
// ✅ Wrapper: bei still unregistrieren / nicht registrieren
const registerTeaserHostIfNeeded = React.useCallback(
(key: string) => (el: HTMLDivElement | null) => {
if (!shouldObserveTeasers) {
// wichtig: sauber unhooken, falls vorher beobachtet wurde
registerTeaserHost(key)(null)
return
}
registerTeaserHost(key)(el)
},
[registerTeaserHost, shouldObserveTeasers]
)
React.useEffect(() => { React.useEffect(() => {
if (!openTagsKey) return if (!openTagsKey) return
@ -167,8 +184,9 @@ export default function FinishedDownloadsGalleryView({
const restTags = tags.length - showTags.length const restTags = tags.length - showTags.length
const fullTags = tags.join(', ') const fullTags = tags.join(', ')
const file = baseName(j.output || '') const fileRaw = baseName(j.output || '')
const isHot = file.startsWith('HOT ') const isHot = isHotName(fileRaw)
const file = stripHotPrefix(fileRaw)
const dur = runtimeOf(j) const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j)) const size = formatBytes(sizeBytesOf(j))
@ -201,7 +219,7 @@ export default function FinishedDownloadsGalleryView({
{/* Thumb */} {/* Thumb */}
<div <div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5" className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHost(k)} ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)} onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => onHoverPreviewKeyChange?.(null)} onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
> >

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import HoverPopover from './HoverPopover' import HoverPopover from './HoverPopover'
import { DEFAULT_INLINE_MUTED } from './videoPolicy' import { DEFAULT_INLINE_MUTED, applyInlineVideoPolicy } from './videoPolicy'
type Variant = 'thumb' | 'fill' type Variant = 'thumb' | 'fill'
type InlineVideoMode = false | true | 'always' | 'hover' type InlineVideoMode = false | true | 'always' | 'hover'
@ -34,6 +34,7 @@ export type FinishedVideoPreviewProps = {
variant?: Variant variant?: Variant
className?: string className?: string
showPopover?: boolean showPopover?: boolean
blur?: boolean blur?: boolean
@ -58,6 +59,8 @@ export type FinishedVideoPreviewProps = {
/** Popover-Video muted? (Default: true) */ /** Popover-Video muted? (Default: true) */
popoverMuted?: boolean popoverMuted?: boolean
noGenerateTeaser?: boolean
} }
export default function FinishedVideoPreview({ export default function FinishedVideoPreview({
@ -90,6 +93,7 @@ export default function FinishedVideoPreview({
muted = DEFAULT_INLINE_MUTED, muted = DEFAULT_INLINE_MUTED,
popoverMuted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED,
noGenerateTeaser,
}: FinishedVideoPreviewProps) { }: FinishedVideoPreviewProps) {
const file = getFileName(job.output || '') const file = getFileName(job.output || '')
const blurCls = blur ? 'blur-md' : '' const blurCls = blur ? 'blur-md' : ''
@ -105,6 +109,7 @@ export default function FinishedVideoPreview({
const [metaLoaded, setMetaLoaded] = useState(false) const [metaLoaded, setMetaLoaded] = useState(false)
const [teaserReady, setTeaserReady] = useState(false) const [teaserReady, setTeaserReady] = useState(false)
const [teaserOk, setTeaserOk] = useState(true)
// inView (Viewport) // inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null) const rootRef = useRef<HTMLDivElement | null>(null)
@ -163,7 +168,8 @@ export default function FinishedVideoPreview({
useEffect(() => { useEffect(() => {
setTeaserReady(false) setTeaserReady(false)
}, [previewId, assetNonce]) setTeaserOk(true)
}, [previewId, assetNonce, noGenerateTeaser])
useEffect(() => { useEffect(() => {
const onRelease = (ev: any) => { const onRelease = (ev: any) => {
@ -245,8 +251,9 @@ export default function FinishedVideoPreview({
const teaserSrc = useMemo(() => { const teaserSrc = useMemo(() => {
if (!previewId) return '' if (!previewId) return ''
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&v=${v}` const noGen = noGenerateTeaser ? '&noGenerate=1' : ''
}, [previewId, v]) return `/api/generated/teaser?id=${encodeURIComponent(previewId)}${noGen}&v=${v}`
}, [previewId, v, noGenerateTeaser])
// ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!) // ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!)
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => { const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
@ -256,15 +263,15 @@ export default function FinishedVideoPreview({
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs) if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
} }
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
useEffect(() => { useEffect(() => {
setThumbOk(true) setThumbOk(true)
setVideoOk(true) setVideoOk(true)
}, [previewId, assetNonce]) }, [previewId, assetNonce])
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// --- Inline Video sichtbar? // --- Inline Video sichtbar?
const showingInlineVideo = const showingInlineVideo =
inlineMode !== 'never' && inlineMode !== 'never' &&
@ -281,9 +288,7 @@ export default function FinishedVideoPreview({
!showingInlineVideo && !showingInlineVideo &&
(animatedTrigger === 'always' || hovered) && (animatedTrigger === 'always' || hovered) &&
( (
// ✅ neuer schneller Modus (animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) ||
(animatedMode === 'teaser' && Boolean(teaserSrc)) ||
// Legacy: clips nur wenn Duration bekannt
(animatedMode === 'clips' && hasDuration) (animatedMode === 'clips' && hasDuration)
) )
@ -318,7 +323,6 @@ export default function FinishedVideoPreview({
const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes]) const clipTimesKey = useMemo(() => clipTimes.map((t) => t.toFixed(2)).join(','), [clipTimes])
const teaserRef = useRef<HTMLVideoElement | null>(null)
const clipIdxRef = useRef(0) const clipIdxRef = useRef(0)
const clipStartRef = useRef(0) const clipStartRef = useRef(0)
@ -332,21 +336,20 @@ export default function FinishedVideoPreview({
return return
} }
// iOS/Safari: Eigenschaften wirklich als Properties setzen applyInlineVideoPolicy(v, { muted })
v.muted = Boolean(muted)
// @ts-ignore
v.defaultMuted = Boolean(muted)
v.playsInline = true
v.setAttribute('playsinline', '')
v.setAttribute('webkit-playsinline', '')
const p = v.play?.() const p = v.play?.()
if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {}) if (p && typeof (p as any).catch === 'function') (p as Promise<void>).catch(() => {})
}, [teaserActive, animatedMode, teaserSrc, muted]) }, [teaserActive, animatedMode, teaserSrc, muted])
useEffect(() => {
if (!showingInlineVideo) return
applyInlineVideoPolicy(inlineRef.current, { muted })
}, [showingInlineVideo, muted])
// Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek // Legacy: "clips" spielt 1s Segmente aus dem Vollvideo per seek
useEffect(() => { useEffect(() => {
const v = teaserRef.current const v = clipsRef.current
if (!v) return if (!v) return
if (!(teaserActive && animatedMode === 'clips')) { if (!(teaserActive && animatedMode === 'clips')) {
@ -463,14 +466,17 @@ export default function FinishedVideoPreview({
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined} poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => setTeaserReady(true)} onLoadedData={() => setTeaserReady(true)}
onPlaying={() => setTeaserReady(true)} onPlaying={() => setTeaserReady(true)}
onError={() => setVideoOk(false)} onError={() => {
setTeaserOk(false) // ✅ nur teaser abschalten
setTeaserReady(false) // ✅ overlay wieder weg
}}
/> />
) : null} ) : null}
{/* ✅ Legacy clips (falls noch genutzt) */} {/* ✅ Legacy clips (falls noch genutzt) */}
{!showingInlineVideo && teaserActive && animatedMode === 'clips' ? ( {!showingInlineVideo && teaserActive && animatedMode === 'clips' ? (
<video <video
ref={teaserRef} ref={clipsRef}
key={`clips-${previewId}-${clipTimesKey}`} key={`clips-${previewId}-${clipTimesKey}`}
src={videoSrc} src={videoSrc}
className={[ className={[

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\ModelDetails.tsx
'use client' 'use client'
import * as React from 'react' import * as React from 'react'
@ -93,25 +95,35 @@ function baseName(path: string) {
return (path || '').split(/[\\/]/).pop() || '' return (path || '').split(/[\\/]/).pop() || ''
} }
function modelKeyFromFilename(fileName: string) { function normPath(p: string) {
const stem = fileName.replace(/\.[^.]+$/, '') return String(p || '').replaceAll('\\', '/')
const normalized = stem.startsWith('HOT ') ? stem.slice(4) : stem
// ✅ match: <model>_DD_MM_YYYY__HH-MM-SS oder <model>_DD_MM_YYYY_HH_MM_SS
const m1 = normalized.match(/^(.+?)_\d{2}_\d{2}_\d{4}(?:__|_)\d{2}[-_]\d{2}[-_]\d{2}/)
if (m1?.[1]) return m1[1].trim().toLowerCase()
// ✅ fallback: <model>_DD_MM_YYYY (ohne Uhrzeit)
const m2 = normalized.match(/^(.+?)_\d{2}_\d{2}_\d{4}/)
if (m2?.[1]) return m2[1].trim().toLowerCase()
// ✅ last fallback: vor dem ersten "_"
const i = normalized.indexOf('_')
if (i > 0) return normalized.slice(0, i).trim().toLowerCase()
return normalized.trim().toLowerCase()
} }
// ✅ liefert modelKey aus /keep/<modelname>/...
function modelKeyFromKeepPath(output?: string | null) {
const p = normPath(output || '').toLowerCase()
const m = p.match(/\/keep\/([^/]+)\//)
return (m?.[1] || '').trim().toLowerCase()
}
function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name
}
function modelNameFromOutput(output?: string) {
const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
// match: <model>_DD_MM_YYYY__HH-MM-SS
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
function stripHtmlToText(input?: string | null) { function stripHtmlToText(input?: string | null) {
if (!input) return '' if (!input) return ''
@ -465,17 +477,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
} }
}, [open, key, bioRefreshSeq, cookies]) }, [open, key, bioRefreshSeq, cookies])
// Done downloads (inkl. done/keep) // Done downloads (inkl. done + keep/<model>/) -> ALLES laden, Pagination client-side
React.useEffect(() => { React.useEffect(() => {
if (!open) return if (!open) return
let alive = true let alive = true
setDoneLoading(true) setDoneLoading(true)
const url = `/api/record/done?page=${donePage}&pageSize=${DONE_PAGE_SIZE}&sort=completed_desc&includeKeep=1`
const url = `/api/record/done?all=1&sort=completed_desc&includeKeep=1`
fetch(url, { cache: 'no-store' }) fetch(url, { cache: 'no-store' })
.then((r) => r.json()) .then((r) => r.json())
.then((data: RecordJob[]) => { .then((data: any) => {
if (!alive) return if (!alive) return
setDone(Array.isArray(data) ? data : [])
// ✅ robust: Backend kann Array ODER {items:[...]} liefern
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
setDone(items)
}) })
.catch(() => { .catch(() => {
if (!alive) return if (!alive) return
@ -485,10 +508,11 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
if (!alive) return if (!alive) return
setDoneLoading(false) setDoneLoading(false)
}) })
return () => { return () => {
alive = false alive = false
} }
}, [open, donePage]) }, [open])
// Running jobs // Running jobs
React.useEffect(() => { React.useEffect(() => {
@ -525,12 +549,35 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
const doneMatches = React.useMemo(() => { const doneMatches = React.useMemo(() => {
if (!key) return [] if (!key) return []
return done.filter((j) => modelKeyFromFilename(baseName(j.output || '')) === key)
return done.filter((j) => {
const out = j.output || ''
// ✅ 1) Wenn es aus /done/keep/<model>/ kommt: Ordnername ist Wahrheit
const keepKey = modelKeyFromKeepPath(out)
if (keepKey) return keepKey === key
// ✅ 2) Sonst: /done/ -> Modellname aus Dateiname
const m = modelNameFromOutput(out)
return m !== '—' && m.trim().toLowerCase() === key
})
}, [done, key]) }, [done, key])
const doneTotalPages = React.useMemo(() => {
return Math.max(1, Math.ceil(doneMatches.length / DONE_PAGE_SIZE))
}, [doneMatches.length])
const doneMatchesPage = React.useMemo(() => {
const start = (donePage - 1) * DONE_PAGE_SIZE
return doneMatches.slice(start, start + DONE_PAGE_SIZE)
}, [doneMatches, donePage])
const runningMatches = React.useMemo(() => { const runningMatches = React.useMemo(() => {
if (!key) return [] if (!key) return []
return runningList.filter((j) => modelKeyFromFilename(baseName(j.output || '')) === key) return runningList.filter((j) => {
const m = modelNameFromOutput(j.output)
return m !== '—' && m.trim().toLowerCase() === key
})
}, [runningList, key]) }, [runningList, key])
const allTags = React.useMemo(() => { const allTags = React.useMemo(() => {
@ -1116,15 +1163,24 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="secondary" disabled={donePage <= 1} onClick={() => setDonePage((p) => Math.max(1, p - 1))}>
Zurück
</Button>
<span className="text-xs text-gray-600 dark:text-gray-300">Seite {donePage}</span>
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
disabled={doneLoading || doneMatches.length < DONE_PAGE_SIZE} disabled={doneLoading || donePage <= 1}
onClick={() => setDonePage((p) => p + 1)} onClick={() => setDonePage((p) => Math.max(1, p - 1))}
>
Zurück
</Button>
<span className="text-xs text-gray-600 dark:text-gray-300">
Seite {donePage} / {doneTotalPages}
</span>
<Button
size="sm"
variant="secondary"
disabled={doneLoading || donePage >= doneTotalPages}
onClick={() => setDonePage((p) => Math.min(doneTotalPages, p + 1))}
> >
Weiter Weiter
</Button> </Button>
@ -1138,7 +1194,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
<div className="text-sm text-gray-600 dark:text-gray-300">Keine abgeschlossenen Downloads für dieses Model gefunden.</div> <div className="text-sm text-gray-600 dark:text-gray-300">Keine abgeschlossenen Downloads für dieses Model gefunden.</div>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-2">
{doneMatches.map((j) => { {doneMatchesPage.map((j) => {
const file = baseName(j.output || '') const file = baseName(j.output || '')
const kept = isKeptOutputPath(j.output || '') const kept = isKeptOutputPath(j.output || '')
const hot = file.startsWith('HOT ') const hot = file.startsWith('HOT ')

View File

@ -178,7 +178,7 @@ export default function ModelPreview({
// HLS nur für große Vorschau im Popover // HLS nur für große Vorschau im Popover
const hq = useMemo( const hq = useMemo(
() => () =>
`/api/record/preview?id=${encodeURIComponent(jobId)}&file=index_hq.m3u8`, `/api/record/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
[jobId] [jobId]
) )

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,7 @@ type Props = {
export default function RecorderSettings({ onAssetsGenerated }: Props) { export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS) const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null) const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
@ -193,6 +194,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
} }
} }
async function cleanupSmallDone() {
setErr(null)
setMsg(null)
const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0)
const doneDir = (value.doneDir || DEFAULTS.doneDir).trim()
if (!doneDir) {
setErr('doneDir ist leer.')
return
}
if (!mb || mb <= 0) {
setErr('Mindestgröße ist 0 es würde nichts gelöscht.')
return
}
const ok = window.confirm(`Aufräumen: Alle Dateien in "${doneDir}" löschen, die kleiner als ${mb} MB sind? (Ordner "keep" wird übersprungen)`)
if (!ok) return
setCleaning(true)
try {
const res = await fetch('/api/settings/cleanup-small-downloads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
})
if (!res.ok) {
const t = await res.text().catch(() => '')
throw new Error(t || `HTTP ${res.status}`)
}
const data = await res.json()
setMsg(
`🧹 Aufräumen fertig: ${data.deletedFiles} Datei(en) gelöscht (${data.deletedBytesHuman}). ` +
`Geprüft: ${data.scannedFiles}. Übersprungen: ${data.skippedFiles}.`
)
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setCleaning(false)
}
}
return ( return (
<Card <Card
header={ header={
@ -229,7 +274,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div> <div className="text-sm font-semibold text-gray-900 dark:text-white">Tasks</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Generiere fehlende Vorschauen/Metadaten (z.B. Duration via meta.json) für schnelle Listenansichten. Generiere fehlende Vorschauen/Metadaten für schnelle Listenansichten.
</div> </div>
</div> </div>
@ -405,7 +450,18 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
/> />
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span> <span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">MB</span>
<Button
variant="secondary"
onClick={cleanupSmallDone}
disabled={saving || cleaning || !value.autoDeleteSmallDownloads}
className="h-9 shrink-0 px-3"
title="Löscht Dateien im doneDir kleiner als die Mindestgröße (keep wird übersprungen)"
>
{cleaning ? '…' : 'Aufräumen'}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -37,8 +37,8 @@ export type SwipeCardProps = {
className?: string className?: string
/** Action-Bereiche */ /** Action-Bereiche */
leftAction?: SwipeAction // standard: Behalten leftAction?: SwipeAction // standard: Behalten
rightAction?: SwipeAction // standard: Löschen rightAction?: SwipeAction // standard: Löschen
/** Ab welcher Strecke wird ausgelöst? */ /** Ab welcher Strecke wird ausgelöst? */
thresholdPx?: number thresholdPx?: number
@ -47,7 +47,7 @@ export type SwipeCardProps = {
/** Animation timings */ /** Animation timings */
snapMs?: number snapMs?: number
commitMs?: number commitMs?: number
/** /**
* Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet. * Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet.
* Praktisch für native Video-Controls (Progressbar) beim Inline-Playback. * Praktisch für native Video-Controls (Progressbar) beim Inline-Playback.
* Beispiel: 72 (px) = unterste 72px sind "swipe-frei". * Beispiel: 72 (px) = unterste 72px sind "swipe-frei".
@ -60,13 +60,11 @@ export type SwipeCardProps = {
*/ */
ignoreSelector?: string ignoreSelector?: string
/** /**
* Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll. * Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll.
* (z.B. Buttons/Inputs innerhalb der Karte) * (z.B. Buttons/Inputs innerhalb der Karte)
*/ */
tapIgnoreSelector?: string tapIgnoreSelector?: string
} }
export type SwipeCardHandle = { export type SwipeCardHandle = {
@ -114,10 +112,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
}, },
ref ref
) { ) {
const cardRef = React.useRef<HTMLDivElement | null>(null) const cardRef = React.useRef<HTMLDivElement | null>(null)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move) // ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
const dxRef = React.useRef(0) const dxRef = React.useRef(0)
const rafRef = React.useRef<number | null>(null) const rafRef = React.useRef<number | null>(null)
@ -153,41 +150,40 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const commit = React.useCallback( const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => { async (dir: 'left' | 'right', runAction: boolean) => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (rafRef.current != null) { const el = cardRef.current
cancelAnimationFrame(rafRef.current) const w = el?.offsetWidth || 360
rafRef.current = null
}
const el = cardRef.current // rausfliegen lassen
const w = el?.offsetWidth || 360 setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
// rausfliegen lassen let ok: boolean | void = true
setAnimMs(commitMs) if (runAction) {
setArmedDir(dir === 'right' ? 'right' : 'left')
const outDx = dir === 'right' ? w + 40 : -(w + 40)
dxRef.current = outDx
setDx(outDx)
let ok: boolean | void = true
if (runAction) {
try { try {
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft() ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
} catch { } catch {
ok = false ok = false
}
} }
}
// wenn Aktion fehlschlägt => zurücksnappen // wenn Aktion fehlschlägt => zurücksnappen
if (ok === false) { if (ok === false) {
setAnimMs(snapMs) setAnimMs(snapMs)
setArmedDir(null) setArmedDir(null)
setDx(0) setDx(0)
window.setTimeout(() => setAnimMs(0), snapMs) window.setTimeout(() => setAnimMs(0), snapMs)
return false return false
} }
return true return true
}, },
[commitMs, onSwipeLeft, onSwipeRight, snapMs] [commitMs, onSwipeLeft, onSwipeRight, snapMs]
) )
@ -195,9 +191,9 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
React.useImperativeHandle( React.useImperativeHandle(
ref, ref,
() => ({ () => ({
swipeLeft: (opts) => commit('left', opts?.runAction ?? true), swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
swipeRight: (opts) => commit('right', opts?.runAction ?? true), swipeRight: (opts) => commit('right', opts?.runAction ?? true),
reset: () => reset(), reset: () => reset(),
}), }),
[commit, reset] [commit, reset]
) )
@ -214,10 +210,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)} )}
/> />
<div <div className={cn('absolute inset-0 flex items-center transition-all duration-200 ease-out')}
className={cn(
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
)}
style={{ style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`, transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1, opacity: dx === 0 ? 0 : 1,
@ -300,9 +293,7 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ dxRef reset (neue Gesture) // ✅ dxRef reset (neue Gesture)
dxRef.current = 0 dxRef.current = 0
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return if (pointer.current.id !== e.pointerId) return
@ -351,12 +342,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
setArmedDir((prev) => (prev === nextDir ? prev : nextDir)) setArmedDir((prev) => (prev === nextDir ? prev : nextDir))
}} }}
onPointerUp={(e) => { onPointerUp={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return if (pointer.current.id !== e.pointerId) return
const threshold = thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio) const threshold =
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const wasDragging = pointer.current.dragging const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured const wasCaptured = pointer.current.captured
@ -405,7 +397,6 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
} }
dxRef.current = 0 dxRef.current = 0
}} }}
onPointerCancel={(e) => { onPointerCancel={(e) => {
if (!enabled || disabled) return if (!enabled || disabled) return
@ -442,4 +433,4 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
) )
}) })
export default SwipeCard export default SwipeCard

View File

@ -0,0 +1,8 @@
// HOT nur, wenn es wirklich am String-Anfang steht (keine trimStart-Magie)
const HOT_RE = /^HOT[ \u00A0]+/i
const normSpaces = (s: string) => String(s || '').replaceAll('\u00A0', ' ')
export const isHotName = (s: string) => HOT_RE.test(normSpaces(s))
export const stripHotPrefix = (s: string) => normSpaces(s).replace(HOT_RE, '')

View File

@ -1,5 +1,5 @@
// components/ui/videoPolicy.ts // components/ui/videoPolicy.ts
export const DEFAULT_INLINE_MUTED = false export const DEFAULT_INLINE_MUTED = true
export const DEFAULT_PLAYER_START_MUTED = false export const DEFAULT_PLAYER_START_MUTED = false
export function applyInlineVideoPolicy( export function applyInlineVideoPolicy(
@ -11,7 +11,11 @@ export function applyInlineVideoPolicy(
// Autoplay klappt am zuverlässigsten mit muted + playsInline // Autoplay klappt am zuverlässigsten mit muted + playsInline
el.muted = muted el.muted = muted
// @ts-ignore (Safari)
el.defaultMuted = muted el.defaultMuted = muted
el.playsInline = true el.playsInline = true
el.setAttribute('playsinline', 'true')
// iOS/Safari: Attribute "present" ist entscheidend (nicht "true"/"false")
el.setAttribute('playsinline', '')
el.setAttribute('webkit-playsinline', '')
} }

View File

@ -10,6 +10,7 @@ export type ChaturbateOnlineRoom = {
export type ChaturbateOnlineResponse = { export type ChaturbateOnlineResponse = {
enabled: boolean enabled: boolean
rooms: ChaturbateOnlineRoom[] rooms: ChaturbateOnlineRoom[]
total?: number
} }
type OnlineState = ChaturbateOnlineResponse type OnlineState = ChaturbateOnlineResponse
@ -37,9 +38,14 @@ export function startChaturbateOnlinePolling(opts: {
getShow: () => string[] getShow: () => string[]
onData: (data: OnlineState) => void onData: (data: OnlineState) => void
intervalMs?: number intervalMs?: number
// ✅ NEU: wenn getModels() leer ist, trotzdem einmal call machen (für "ALL online")
fetchAllWhenNoModels?: boolean
/** Optional: wird bei Fehlern aufgerufen (für Debug) */ /** Optional: wird bei Fehlern aufgerufen (für Debug) */
onError?: (err: unknown) => void onError?: (err: unknown) => void
}) { }) {
const baseIntervalMs = opts.intervalMs ?? 5000 const baseIntervalMs = opts.intervalMs ?? 5000
let timer: number | null = null let timer: number | null = null
@ -86,21 +92,26 @@ export function startChaturbateOnlinePolling(opts: {
const show = showRaw.slice().sort() const show = showRaw.slice().sort()
const modelsSorted = models.slice().sort() const modelsSorted = models.slice().sort()
// keine Models -> rooms leeren (enabled nicht neu erfinden) // ✅ ALL-mode, wenn keine Models und Option aktiv
if (modelsSorted.length === 0) { const isAllMode = modelsSorted.length === 0 && Boolean(opts.fetchAllWhenNoModels)
// keine Models -> normalerweise rooms leeren (enabled nicht neu erfinden)
if (modelsSorted.length === 0 && !isAllMode) {
closeInFlight() closeInFlight()
const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] } const empty: OnlineState = { enabled: lastResult?.enabled ?? false, rooms: [] }
lastResult = empty lastResult = empty
opts.onData(empty) opts.onData(empty)
// hidden tab -> seltener pollen
const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs const nextMs = document.hidden ? Math.max(15000, baseIntervalMs) : baseIntervalMs
schedule(nextMs) schedule(nextMs)
return return
} }
const key = `${show.join(',')}|${modelsSorted.join(',')}` // ✅ In ALL-mode senden wir q:[] (1 Request). Sonst normale Liste.
const modelsForRequest = isAllMode ? [] : modelsSorted
const key = `${show.join(',')}|${isAllMode ? '__ALL__' : modelsForRequest.join(',')}`
const requestKey = key const requestKey = key
lastKey = key lastKey = key
@ -109,12 +120,14 @@ export function startChaturbateOnlinePolling(opts: {
const controller = new AbortController() const controller = new AbortController()
inFlight = controller inFlight = controller
// ✅ Wichtig: "keepalive" NICHT setzen (kann Ressourcen kosten)
const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200300 nehmen const CHUNK_SIZE = 350 // wenn du extrem viele Keys hast: 200300 nehmen
const parts = chunk(modelsSorted, CHUNK_SIZE)
// ✅ ALL-mode: genau ein Part mit [] schicken
const parts = isAllMode ? [[]] : chunk(modelsForRequest, CHUNK_SIZE)
let mergedRooms: ChaturbateOnlineRoom[] = [] let mergedRooms: ChaturbateOnlineRoom[] = []
let mergedEnabled = false let mergedEnabled = false
let mergedTotal = 0
let hadAnyOk = false let hadAnyOk = false
for (const part of parts) { for (const part of parts) {
@ -136,6 +149,10 @@ export function startChaturbateOnlinePolling(opts: {
const data = (await res.json()) as OnlineState const data = (await res.json()) as OnlineState
mergedEnabled = mergedEnabled || Boolean(data?.enabled) mergedEnabled = mergedEnabled || Boolean(data?.enabled)
mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : [])) mergedRooms.push(...(Array.isArray(data?.rooms) ? data.rooms : []))
// ✅ NEU: total mergen (Backend liefert Gesamtzahl)
const t = Number((data as any)?.total ?? 0)
if (Number.isFinite(t) && t > mergedTotal) mergedTotal = t
} }
if (!hadAnyOk) { if (!hadAnyOk) {
@ -144,7 +161,7 @@ export function startChaturbateOnlinePolling(opts: {
return return
} }
const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms) } const merged: OnlineState = { enabled: mergedEnabled, rooms: dedupeRooms(mergedRooms), total: mergedTotal }
if (controller.signal.aborted) return if (controller.signal.aborted) return
if (requestKey !== lastKey) return if (requestKey !== lastKey) return

View File

@ -6,7 +6,7 @@ import { ToastProvider } from './components/ui/ToastProvider.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ToastProvider position="bottom-right" maxToasts={3} defaultDurationMs={3500}> <ToastProvider position="top-right" maxToasts={3} defaultDurationMs={3500}>
<App /> <App />
</ToastProvider> </ToastProvider>
</StrictMode>, </StrictMode>,

View File

@ -11,6 +11,9 @@ export type RecordJob = {
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live) // ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
durationSeconds?: number durationSeconds?: number
sizeBytes?: number sizeBytes?: number
videoWidth?: number
videoHeight?: number
fps?: number
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt) // ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
phase?: string phase?: string