From 691c24e899c01ed2b0f35295b06699606b5eb4c9 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:18:33 +0200 Subject: [PATCH] ui fixes --- backend/analyze.go | 23 +- .../data/pending-autostart/__global__.json | 106 +- backend/models_api.go | 6 +- backend/models_store.go | 33 +- frontend/src/App.tsx | 1345 +++++++++-------- frontend/src/components/ui/CountryFlag.tsx | 246 +++ .../src/components/ui/DownloadContextMenu.tsx | 75 - frontend/src/components/ui/Downloads.tsx | 53 +- .../src/components/ui/DownloadsCardRow.tsx | 20 +- .../src/components/ui/FinishedDownloads.tsx | 533 ++++--- .../ui/FinishedDownloadsCardsView.tsx | 745 +++++++-- .../ui/FinishedDownloadsGalleryCard.tsx | 107 +- .../ui/FinishedDownloadsGalleryView.tsx | 242 +-- .../ui/FinishedDownloadsTableView.tsx | 57 +- frontend/src/components/ui/ModelDetails.tsx | 187 +-- .../src/components/ui/ModelGenderIcon.tsx | 10 +- frontend/src/components/ui/ModelsTab.tsx | 333 ++-- frontend/src/components/ui/Player.tsx | 4 +- frontend/src/components/ui/Slider.tsx | 62 +- frontend/src/components/ui/TagBadge.tsx | 20 +- frontend/src/components/ui/TagFilterBar.tsx | 265 ++++ .../src/components/ui/finishedModelFlags.ts | 143 ++ frontend/src/components/ui/tagUtils.ts | 24 + frontend/src/types.ts | 4 +- 24 files changed, 2993 insertions(+), 1650 deletions(-) create mode 100644 frontend/src/components/ui/CountryFlag.tsx delete mode 100644 frontend/src/components/ui/DownloadContextMenu.tsx create mode 100644 frontend/src/components/ui/TagFilterBar.tsx create mode 100644 frontend/src/components/ui/finishedModelFlags.ts create mode 100644 frontend/src/components/ui/tagUtils.ts diff --git a/backend/analyze.go b/backend/analyze.go index 288765b..5f2b6c7 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -166,18 +166,25 @@ func nsfwLabelPriority(label string) int { label = strings.ToLower(strings.TrimSpace(label)) switch label { - case - "anus_exposed", - "female_genitalia_exposed", - "male_genitalia_exposed", - "female_breast_exposed", - "buttocks_exposed": - return 300 + case "female_genitalia_exposed": + return 1000 + + case "female_breast_exposed": + return 900 + + case "anus_exposed": + return 800 + + case "male_genitalia_exposed": + return 700 + + case "buttocks_exposed": + return 600 case "female_genitalia_covered", - "male_genitalia_covered", "female_breast_covered", + "male_genitalia_covered", "buttocks_covered", "male_breast_exposed", "male_breast_covered": diff --git a/backend/data/pending-autostart/__global__.json b/backend/data/pending-autostart/__global__.json index 3be17b8..91a5e39 100644 --- a/backend/data/pending-autostart/__global__.json +++ b/backend/data/pending-autostart/__global__.json @@ -1,114 +1,114 @@ { "items": [ { - "modelKey": "jam_cream", - "url": "https://www.myfreecams.com/#Jam_cream", + "modelKey": "sw33t_g1rl", + "url": "https://www.myfreecams.com/#Sw33t_g1rl", "mode": "probe_retry", - "nextProbeAtMs": 1775899166952, + "nextProbeAtMs": 1776172330458, "currentShow": "offline", "source": "watched" }, { - "modelKey": "milk_bunny_", - "url": "https://chaturbate.com/milk_bunny_/", + "modelKey": "angel_from_sky", + "url": "https://chaturbate.com/angel_from_sky/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg", + "source": "watched" + }, + { + "modelKey": "vanicha_007", + "url": "https://chaturbate.com/vanicha_007/", "mode": "wait_public", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/milk_bunny_.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg", "source": "watched" }, { - "modelKey": "kali_hiill", - "url": "https://chaturbate.com/kali_hiill/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/kali_hiill.jpg", - "source": "watched" - }, - { - "modelKey": "passiondesire_", - "url": "https://chaturbate.com/passiondesire_/", - "mode": "wait_public", - "currentShow": "away", - "imageUrl": "https://thumb.live.mmcdn.com/ri/passiondesire_.jpg", - "source": "watched" - }, - { - "modelKey": "tsukuyami_mi", - "url": "https://chaturbate.com/tsukuyami_mi/", + "modelKey": "tomokosturgeon", + "url": "https://chaturbate.com/tomokosturgeon/", "mode": "wait_public", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/tsukuyami_mi.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg", "source": "watched" }, { - "modelKey": "swtty_meivi", - "url": "https://chaturbate.com/swtty_meivi/", + "modelKey": "tammieprotano", + "url": "https://chaturbate.com/tammieprotano/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/swtty_meivi.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg", "source": "watched" }, { - "modelKey": "amandatalk", - "url": "https://chaturbate.com/amandatalk/", + "modelKey": "troybailly", + "url": "https://chaturbate.com/troybailly/", + "mode": "wait_public", + "currentShow": "hidden", + "imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg", + "source": "watched" + }, + { + "modelKey": "vonen_ray", + "url": "https://chaturbate.com/vonen_ray/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/amandatalk.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg", "source": "watched" }, { - "modelKey": "oliver_and_megan", - "url": "https://chaturbate.com/oliver_and_megan/", + "modelKey": "sharitaklemme", + "url": "https://chaturbate.com/sharitaklemme/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/oliver_and_megan.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg", "source": "watched" }, { - "modelKey": "aurora_asti_", - "url": "https://chaturbate.com/aurora_asti_/", + "modelKey": "sosaguillypleasure", + "url": "https://chaturbate.com/sosaguillypleasure/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_asti_.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg", "source": "watched" }, { - "modelKey": "wasianbby", - "url": "https://chaturbate.com/wasianbby/", + "modelKey": "kittymeow_xxx", + "url": "https://chaturbate.com/kittymeow_xxx/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/wasianbby.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg", "source": "watched" }, { - "modelKey": "gimbobar", - "url": "https://chaturbate.com/gimbobar/", + "modelKey": "superstar090", + "url": "https://chaturbate.com/superstar090/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/gimbobar.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg", "source": "watched" }, { - "modelKey": "lizbethbiers", - "url": "https://chaturbate.com/lizbethbiers/", + "modelKey": "victoryglow", + "url": "https://chaturbate.com/victoryglow/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/lizbethbiers.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg", "source": "watched" }, { - "modelKey": "bonnettbonett", - "url": "https://chaturbate.com/bonnettbonett/", + "modelKey": "palmiratownzen", + "url": "https://chaturbate.com/palmiratownzen/", "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/bonnettbonett.jpg", + "currentShow": "hidden", + "imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg", "source": "watched" }, { - "modelKey": "pazrapacki", - "url": "https://chaturbate.com/pazrapacki/", + "modelKey": "rafaelaloghry", + "url": "https://chaturbate.com/rafaelaloghry/", "mode": "probe_retry", - "nextProbeAtMs": 1775899181131, + "nextProbeAtMs": 1776172331673, "currentShow": "unknown", "source": "watched" } diff --git a/backend/models_api.go b/backend/models_api.go index 7565651..68b2205 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -145,10 +145,10 @@ type modelsOverviewModel struct { Input string `json:"input"` Host string `json:"host,omitempty"` ModelKey string `json:"modelKey"` + Tags string `json:"tags,omitempty"` - Gender string `json:"gender,omitempty"` - Sex string `json:"sex,omitempty"` - Subgender string `json:"subgender,omitempty"` + Gender string `json:"gender,omitempty"` + Country string `json:"country,omitempty"` Watching bool `json:"watching"` Favorite bool `json:"favorite,omitempty"` diff --git a/backend/models_store.go b/backend/models_store.go index 95febf0..e47e249 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -24,9 +24,8 @@ type StoredModel struct { Path string `json:"path,omitempty"` ModelKey string `json:"modelKey"` // Display/Key - Gender string `json:"gender,omitempty"` - Sex string `json:"sex,omitempty"` - Subgender string `json:"subgender,omitempty"` + Gender string `json:"gender,omitempty"` + Country string `json:"country,omitempty"` Tags string `json:"tags,omitempty"` LastStream string `json:"lastStream,omitempty"` // RFC3339Nano @@ -1053,27 +1052,25 @@ ORDER BY updated_at DESC; continue } - var gender, sex, subgender string + var gender, country string if strings.TrimSpace(cbOnlineJSON) != "" { var snap ChaturbateOnlineSnapshot if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { gender = strings.TrimSpace(snap.Gender) + country = strings.TrimSpace(snap.Country) } } m := StoredModel{ - ID: id, - Input: input, - IsURL: isURL, - Host: host, - Path: path, - ModelKey: modelKey, - - Gender: gender, - Sex: sex, - Subgender: subgender, - + ID: id, + Input: input, + IsURL: isURL, + Host: host, + Path: path, + ModelKey: modelKey, + Gender: gender, + Country: country, Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), @@ -1889,12 +1886,13 @@ WHERE id=$1; } } - var gender, sex, subgender string + var gender, country string if strings.TrimSpace(cbOnlineJSON) != "" { var snap ChaturbateOnlineSnapshot if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { gender = strings.TrimSpace(snap.Gender) + country = strings.TrimSpace(snap.Country) } } @@ -1907,8 +1905,7 @@ WHERE id=$1; ModelKey: modelKey, Gender: gender, - Sex: sex, - Subgender: subgender, + Country: country, Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c6e638..8d98725 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -149,10 +149,12 @@ type StoredModel = { input: string host?: string modelKey: string - + watching: boolean favorite?: boolean liked?: boolean | null + tags?: string + isUrl?: boolean path?: string @@ -164,6 +166,7 @@ type StoredModel = { gender?: string | null sex?: string | null subgender?: string | null + country?: string | null lastOnlineAt?: string lastOfflineAt?: string @@ -219,6 +222,54 @@ type PendingAutoStartResponse = { items?: PendingAutoStartItem[] } +function mergeStoredModel( + prev?: StoredModel | null, + incoming?: StoredModel | null +): StoredModel { + if (!prev && !incoming) { + return { + id: '', + input: '', + modelKey: '', + watching: false, + } + } + + if (!prev) return incoming as StoredModel + if (!incoming) return prev + + return { + ...prev, + ...incoming, + + id: incoming.id || prev.id, + input: incoming.input || prev.input, + host: incoming.host || prev.host, + modelKey: incoming.modelKey || prev.modelKey, + + watching: incoming.watching ?? prev.watching ?? false, + favorite: incoming.favorite ?? prev.favorite, + liked: incoming.liked ?? prev.liked, + tags: incoming.tags ?? prev.tags, + + isUrl: incoming.isUrl ?? prev.isUrl, + path: incoming.path || prev.path, + + roomStatus: incoming.roomStatus ?? prev.roomStatus, + isOnline: incoming.isOnline ?? prev.isOnline, + chatRoomUrl: incoming.chatRoomUrl || prev.chatRoomUrl, + imageUrl: incoming.imageUrl || prev.imageUrl, + + gender: incoming.gender ?? prev.gender ?? null, + sex: incoming.sex ?? prev.sex ?? null, + subgender: incoming.subgender ?? prev.subgender ?? null, + + lastOnlineAt: incoming.lastOnlineAt ?? prev.lastOnlineAt, + lastOfflineAt: incoming.lastOfflineAt ?? prev.lastOfflineAt, + lastRoomSyncAt: incoming.lastRoomSyncAt ?? prev.lastRoomSyncAt, + } +} + function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { const s = String(v ?? '').trim().toLowerCase() @@ -787,7 +838,7 @@ export default function App() { } }, []) - const BASE_DONE_PAGE_SIZE = 6 + const BASE_DONE_PAGE_SIZE = 8 const [donePageSize, setDonePageSize] = useState(BASE_DONE_PAGE_SIZE) type DoneSortMode = @@ -1281,14 +1332,25 @@ export default function App() { const buildModelsByKey = useCallback((list: StoredModel[]) => { const map: Record = {} + const score = (x: StoredModel) => + (x.favorite ? 4 : 0) + + (x.liked === true ? 2 : 0) + + (x.watching ? 1 : 0) + for (const m of Array.isArray(list) ? list : []) { - const k = (m?.modelKey || '').trim().toLowerCase() + const k = String(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 + if (!cur) { + map[k] = m + continue + } + + const better = score(m) >= score(cur) ? m : cur + const other = better === m ? cur : m + + map[k] = mergeStoredModel(other, better) } return map @@ -1349,622 +1411,7 @@ export default function App() { }) }, []) - type AppModelsSnapshot = { - totalCount: number - onlineModelsCount: number - onlineWatchedModelsCount: number - onlineFavCount: number - onlineLikedCount: number - models: StoredModel[] - } - - const [headerStats, setHeaderStats] = useState({ - totalCount: 0, - onlineModelsCount: 0, - onlineWatchedModelsCount: 0, - onlineFavCount: 0, - onlineLikedCount: 0, - }) - - useEffect(() => { - return () => { - if (pendingJobEventsTimerRef.current != null) { - window.clearTimeout(pendingJobEventsTimerRef.current) - } - } - }, []) - - useEffect(() => { - setModelsCount(headerStats.totalCount) - }, [headerStats.totalCount]) - - const overviewInFlightRef = useRef(false) - const overviewLastSigRef = useRef('') - const overviewLastAtRef = useRef(0) - const overviewTimerRef = useRef(null) - - const loadAppModelsSnapshot = useCallback(async (keys: string[]) => { - const uniqKeys = Array.from( - new Set( - keys - .map((x) => String(x ?? '').trim().toLowerCase()) - .filter(Boolean) - ) - ) - - const sig = uniqKeys.join('|') - const now = Date.now() - - if (overviewInFlightRef.current) return - if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return - - overviewInFlightRef.current = true - overviewLastSigRef.current = sig - overviewLastAtRef.current = now - - try { - const data = await apiJSON('/api/models/overview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - cache: 'no-store' as any, - body: JSON.stringify({ - keys: uniqKeys, - includeWatched: true, - }), - }) - - setHeaderStats({ - totalCount: Number(data?.totalCount ?? 0), - onlineModelsCount: Number(data?.onlineModelsCount ?? 0), - onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), - onlineFavCount: Number(data?.onlineFavCount ?? 0), - onlineLikedCount: Number(data?.onlineLikedCount ?? 0), - }) - - const safeList = Array.isArray(data?.models) ? data.models : [] - setModelsByKey(buildModelsByKey(safeList)) - } catch { - // ignore - } finally { - overviewInFlightRef.current = false - } - }, [buildModelsByKey]) - - const maybeNotifyWatchedModelStatusChange = useCallback(( - keyLower: string, - nextStatusRaw: unknown, - patch?: { - imageUrl?: string - } - ) => { - if (!keyLower) return - if (!recSettingsRef.current.enableNotifications) return - if (!roomStatusBaselineReadyRef.current) return - - const model = modelsByKeyRef.current[keyLower] - if (!model?.watching) { - const nextStatus = normalizeRoomStatusText(nextStatusRaw) - - // unknown nicht als "letzten echten Status" speichern - if (nextStatus !== 'unknown') { - lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus - } - return - } - - const nextStatus = normalizeRoomStatusText(nextStatusRaw) - const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) - - // unknown niemals melden und auch nicht als letzten echten Status überschreiben - if (nextStatus === 'unknown') return - - // keine Änderung - if (prevStatus === nextStatus) return - - // keine "erste Sichtung" als Wechsel melden - const hadPreviousRealStatus = - Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) && - prevStatus !== 'unknown' - - lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus - - if (!hadPreviousRealStatus) return - - const displayName = - String(model?.modelKey ?? keyLower).trim() || keyLower - - const imageUrl = - String(patch?.imageUrl ?? '').trim() || - String(model?.imageUrl ?? '').trim() || - undefined - - const title = - nextStatus === 'public' - ? `${displayName} ist live` - : `${displayName} Status geändert` - - const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}` - - notifyRef.current?.info(title, message, { - imageUrl, - imageAlt: displayName, - durationMs: 3000, - onClick: () => { - window.dispatchEvent( - new CustomEvent('open-model-details', { - detail: { modelKey: keyLower }, - }) - ) - }, - }) - }, []) - - const enqueueStart = useCallback( - (item: StartQueueItem) => { - const norm0 = normalizeHttpUrl(item.url) - if (!norm0) return false - const norm = canonicalizeProviderUrl(norm0) - - // dedupe: gleiche URL nicht 100x in die Queue - if (startQueuedSetRef.current.has(norm)) return true - startQueuedSetRef.current.add(norm) - - startQueueRef.current.push({ ...item, url: norm }) - - // pump einmal pro Tick schedulen - if (!pumpStartQueueScheduledRef.current) { - pumpStartQueueScheduledRef.current = true - queueMicrotask(() => { - pumpStartQueueScheduledRef.current = false - void pumpStartQueue() - }) - } - return true - }, - // pumpStartQueue kommt gleich darunter (useCallback), daher eslint ggf. meckert -> ok, wir definieren pumpStartQueue als function declaration unten - [] - ) - useEffect(() => { - onModelJobEventRef.current = (ev: MessageEvent) => { - try { - const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent - const modelKey = String(msg?.model ?? '').trim().toLowerCase() - - if (msg?.type === 'job_upsert') { - if (modelKey) { - const statusLower = String(msg?.status ?? '').trim().toLowerCase() - const phaseLower = String(msg?.phase ?? '').trim().toLowerCase() - - const isActiveVisibleRecording = - statusLower === 'running' && - (phaseLower === '' || phaseLower === 'recording') - - if (isActiveVisibleRecording) { - removePendingAutoStart(modelKey) - } - - const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() - - if (roomStatus) { - const isOnline = - typeof msg?.isOnline === 'boolean' - ? Boolean(msg.isOnline) - : roomStatus !== 'offline' - - maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, { - imageUrl: String(msg?.modelImageUrl ?? '').trim(), - }) - - applyRoomStateToModel(modelKey, { - roomStatus, - isOnline, - imageUrl: String(msg?.modelImageUrl ?? '').trim(), - chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(), - }) - } - } - } - - queueJobDelta(msg) - } catch { - // ignore - } - } - }, [applyRoomStateToModel, enqueueStart, maybeNotifyWatchedModelStatusChange, queueJobDelta]) - - useEffect(() => { - const onOpen = (ev: Event) => { - const e = ev as CustomEvent<{ modelKey?: string }> - const raw0 = (e.detail?.modelKey ?? '').trim() - if (!raw0) return - - // 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen - // Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key - const looksLikeKey = - !raw0.includes(' ') && - !raw0.includes('/') && - !raw0.includes('\\') - - if (looksLikeKey) { - const k = raw0.replace(/^@/, '').trim().toLowerCase() - if (k) setDetailsModelKey(k) - return - } - - // 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren - const norm0 = normalizeHttpUrl(raw0) - if (!norm0) { - // Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist) - let k = raw0.replace(/^https?:\/\//i, '') - if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k - if (k.includes(':')) k = k.split(':').pop() || k - k = k.trim().toLowerCase() - if (k) setDetailsModelKey(k) - return - } - - const norm = canonicalizeProviderUrl(norm0) - const keyLower = providerKeyLowerFromUrl(norm) - - if (keyLower) setDetailsModelKey(keyLower) - } - - window.addEventListener('open-model-details', onOpen as any) - return () => window.removeEventListener('open-model-details', onOpen as any) - }, []) - - useEffect(() => { - const onOpen = (ev: Event) => { - const e = ev as CustomEvent<{ jobId?: string; output?: string }> - const jobId = String(e.detail?.jobId ?? '').trim() - const output = String(e.detail?.output ?? '').trim() - - let hit: RecordJob | null = null - - if (jobId) { - hit = - jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? - doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? - null - } - - if (!hit && output) { - const wanted = baseName(output) - hit = - jobs.find((j) => baseName(j.output || '') === wanted) ?? - doneJobs.find((j) => baseName(j.output || '') === wanted) ?? - null - } - - if (hit) { - setSplitJob(hit) - setSplitModalKey((k) => k + 1) - setSplitModalOpen(true) - } - } - - window.addEventListener('open-video-splitter', onOpen as EventListener) - return () => window.removeEventListener('open-video-splitter', onOpen as EventListener) - }, [jobs, doneJobs]) - - const upsertModelCache = useCallback((m: StoredModel) => { - const now = Date.now() - const cur = modelsCacheRef.current - if (!cur) { - modelsCacheRef.current = { ts: now, list: [m] } - return - } - cur.ts = now - const idx = cur.list.findIndex((x) => x.id === m.id) - if (idx >= 0) cur.list[idx] = m - else cur.list.unshift(m) - }, []) - - const recSettingsRef = useRef(recSettings) - useEffect(() => { - recSettingsRef.current = recSettings - }, [recSettings]) - - const [autostartState, setAutostartState] = useState({ - paused: false, - pausedByUser: false, - pausedByDisk: false, - }) - - useEffect(() => { - busyRef.current = busy - }, [busy]) - useEffect(() => { - cookiesRef.current = cookies - }, [cookies]) - useEffect(() => { - jobsRef.current = jobs - }, [jobs]) - - // pending start falls gerade busy - const lastClipboardUrlRef = useRef('') - - // --- START QUEUE (parallel) --- - const START_CONCURRENCY = 1 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start - - type StartQueueItem = { - url: string - silent: boolean - pendingKeyLower?: string - hidden?: boolean - source?: PendingAutoStartSource - } - - const startQueueRef = useRef([]) - const startInFlightRef = useRef(0) - const startQueuedSetRef = useRef>(new Set()) // dedupe: verhindert Duplikate - const pumpStartQueueScheduledRef = useRef(false) - - const setBusyFromStarts = useCallback(() => { - const v = startInFlightRef.current > 0 - setBusy(v) - busyRef.current = v - }, []) - - async function doStartNow( - normUrl: string, - silent: boolean, - opts?: { hidden?: boolean } - ): Promise { - normUrl = canonicalizeProviderUrl(normUrl) - - const alreadyRunning = jobsRef.current.some((j) => { - if (String(j.status || '').toLowerCase() !== 'running') return false - if ((j as any).endedAt) return false - - const jNorm0 = normalizeHttpUrl(String((j as any).sourceUrl || '')) - const jNorm = jNorm0 ? canonicalizeProviderUrl(jNorm0) : '' - return jNorm === normUrl - }) - if (alreadyRunning) return true - - try { - const currentCookies = cookiesRef.current - - const provider = getProviderFromNormalizedUrl(normUrl) - if (!provider) { - if (!silent) setError('Nur chaturbate.com oder myfreecams.com werden unterstützt.') - return false - } - - if (provider === 'chaturbate' && !hasRequiredChaturbateCookies(currentCookies)) { - if (!silent) { - setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') - } - return false - } - - const cookieString = Object.entries(currentCookies) - .map(([k, v]) => `${k}=${v}`) - .join('; ') - - const created = await apiJSON('/api/record', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: normUrl, - cookie: cookieString, - hidden: Boolean(opts?.hidden), - }), - }) - - // Wichtig: model lokal sofort mitschleppen, damit requiredModelKeys / - // overview / watched/fav/liked schon vor SSE korrekt funktionieren. - const localModelKey = providerKeyLowerFromUrl(normUrl) - const createdWithModel = localModelKey - ? ({ ...created, model: localModelKey } as RecordJob) - : created - - if ((createdWithModel as any)?.id && !opts?.hidden) { - startedToastByJobIdRef.current[String((createdWithModel as any).id)] = true - } - - if (!(opts?.hidden || Boolean((createdWithModel as any)?.hidden))) { - setJobs((prev) => { - const next = upsertJobById(prev, createdWithModel) - jobsRef.current = next - return next - }) - } - - return true - } catch (e: any) { - const msg = e?.message ?? String(e) - - if (isCookieGateError(msg)) { - showMissingCookiesMessage({ silent }) - return false - } - - if (!silent) setError(msg) - return false - } - } - - async function pumpStartQueue(): Promise { - // so viele wie möglich parallel starten - while (startInFlightRef.current < START_CONCURRENCY && startQueueRef.current.length > 0) { - const next = startQueueRef.current.shift()! - startInFlightRef.current++ - setBusyFromStarts() - - void (async () => { - try { - const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden }) - - if (next.pendingKeyLower && next.source === 'manual') { - if (ok && !next.hidden) { - removePendingAutoStart(next.pendingKeyLower) - } - - if (!ok) { - removePendingAutoStart(next.pendingKeyLower) - } - } - } finally { - // dedupe wieder freigeben - startQueuedSetRef.current.delete(next.url) - - startInFlightRef.current = Math.max(0, startInFlightRef.current - 1) - setBusyFromStarts() - - // falls noch was da ist: weiterpumpen - if (startQueueRef.current.length > 0) { - void pumpStartQueue() - } - } - })() - } - } - - const chaturbateStoreKeysLower = useMemo(() => { - const set = new Set() - for (const m of Object.values(modelsByKey)) { - if (!isChaturbateStoreModel(m)) continue - const k = lower(String(m?.modelKey ?? '')) - if (k) set.add(k) - } - return Array.from(set) - }, [modelsByKey, isChaturbateStoreModel]) - - const watchedModelKeysLower = useMemo(() => { - const set = new Set() - - for (const m of Object.values(modelsByKey)) { - if (!m?.watching) continue - - const k = lower(String(m?.modelKey ?? '')) - if (!k) continue - - set.add(k) - } - - return Array.from(set) - }, [modelsByKey]) - - const requiredModelKeys = useMemo(() => { - const set = new Set() - - for (const j of jobs) { - const k = modelEventKeyFromJob(j) - if (k) set.add(k) - } - - for (const j of doneJobs) { - const k = modelEventKeyFromDoneJob(j) - if (k) set.add(k) - } - - for (const p of pendingWatchedRooms) { - const k = String(p?.modelKey ?? '').trim().toLowerCase() - if (k) set.add(k) - } - - if (playerJob) { - const k = (modelKeyFromFilename(playerJob.output || '') || '').trim().toLowerCase() - if (k) set.add(k) - } - - if (detailsModelKey) { - const k = String(detailsModelKey ?? '').trim().toLowerCase() - if (k) set.add(k) - } - - return Array.from(set).sort() - }, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey]) - - const requiredModelKeysSig = useMemo( - () => requiredModelKeys.join('|'), - [requiredModelKeys] - ) - - const requiredModelKeysRef = useRef(requiredModelKeys) - - useEffect(() => { - requiredModelKeysRef.current = requiredModelKeys - }, [requiredModelKeys]) - - const lastOverviewSigRef = useRef(null) - - useEffect(() => { - if (!authed) return - - const scheduleOverviewLoad = () => { - if (overviewTimerRef.current != null) { - window.clearTimeout(overviewTimerRef.current) - } - - overviewTimerRef.current = window.setTimeout(() => { - void loadAppModelsSnapshot(requiredModelKeys) - }, 250) - } - - if (lastOverviewSigRef.current !== requiredModelKeysSig) { - lastOverviewSigRef.current = requiredModelKeysSig - scheduleOverviewLoad() - } - - const onChanged = (ev: Event) => { - const e = ev as CustomEvent - const detail = e?.detail ?? {} - const updated = detail?.model - - if (updated && typeof updated === 'object') { - const k = String(updated.modelKey ?? '').toLowerCase().trim() - - if (k) { - setModelsByKey((prev) => ({ ...prev, [k]: updated })) - } - - try { - upsertModelCache(updated) - } catch {} - - setPlayerModel((prev) => (prev?.id === updated.id ? updated : prev)) - scheduleOverviewLoad() - return - } - - if (detail?.removed) { - const removedId = String(detail?.id ?? '').trim() - const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim() - - if (removedKey) { - setModelsByKey((prev) => { - const { [removedKey]: _drop, ...rest } = prev - return rest - }) - } - - if (removedId) { - setPlayerModel((prev) => (prev?.id === removedId ? null : prev)) - } - - scheduleOverviewLoad() - return - } - - scheduleOverviewLoad() - } - - window.addEventListener('models-changed', onChanged as any) - - return () => { - window.removeEventListener('models-changed', onChanged as any) - - if (overviewTimerRef.current != null) { - window.clearTimeout(overviewTimerRef.current) - overviewTimerRef.current = null - } - } - }, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) - const queuePendingAutoStart = useCallback(( keyLower: string, url: string, @@ -2133,6 +1580,522 @@ export default function App() { const donePageRef = useRef(donePage) const doneSortRef = useRef(doneSort) + type AppModelsSnapshot = { + totalCount: number + onlineModelsCount: number + onlineWatchedModelsCount: number + onlineFavCount: number + onlineLikedCount: number + models: StoredModel[] + } + + const [headerStats, setHeaderStats] = useState({ + totalCount: 0, + onlineModelsCount: 0, + onlineWatchedModelsCount: 0, + onlineFavCount: 0, + onlineLikedCount: 0, + }) + + useEffect(() => { + return () => { + if (pendingJobEventsTimerRef.current != null) { + window.clearTimeout(pendingJobEventsTimerRef.current) + } + } + }, []) + + useEffect(() => { + setModelsCount(headerStats.totalCount) + }, [headerStats.totalCount]) + + const overviewInFlightRef = useRef(false) + const overviewLastSigRef = useRef('') + const overviewLastAtRef = useRef(0) + const overviewTimerRef = useRef(null) + + const loadAppModelsSnapshot = useCallback(async (keys: string[]) => { + const uniqKeys = Array.from( + new Set( + keys + .map((x) => String(x ?? '').trim().toLowerCase()) + .filter(Boolean) + ) + ) + + const sig = uniqKeys.join('|') + const now = Date.now() + + if (overviewInFlightRef.current) return + if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return + + overviewInFlightRef.current = true + overviewLastSigRef.current = sig + overviewLastAtRef.current = now + + try { + const data = await apiJSON('/api/models/overview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' as any, + body: JSON.stringify({ + keys: uniqKeys, + includeWatched: true, + }), + }) + + setHeaderStats({ + totalCount: Number(data?.totalCount ?? 0), + onlineModelsCount: Number(data?.onlineModelsCount ?? 0), + onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), + onlineFavCount: Number(data?.onlineFavCount ?? 0), + onlineLikedCount: Number(data?.onlineLikedCount ?? 0), + }) + + const safeList = Array.isArray(data?.models) ? data.models : [] + const built = buildModelsByKey(safeList) + + setModelsByKey((prev) => { + const next = { ...prev } + + for (const [k, incoming] of Object.entries(built)) { + next[k] = mergeStoredModel(prev[k], incoming) + } + + return next + }) + } catch { + // ignore + } finally { + overviewInFlightRef.current = false + } + }, [buildModelsByKey]) + + const maybeNotifyWatchedModelStatusChange = useCallback(( + keyLower: string, + nextStatusRaw: unknown, + patch?: { + imageUrl?: string + } + ) => { + if (!keyLower) return + if (!recSettingsRef.current.enableNotifications) return + if (!roomStatusBaselineReadyRef.current) return + + const model = modelsByKeyRef.current[keyLower] + if (!model?.watching) { + const nextStatus = normalizeRoomStatusText(nextStatusRaw) + + // unknown nicht als "letzten echten Status" speichern + if (nextStatus !== 'unknown') { + lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus + } + return + } + + const nextStatus = normalizeRoomStatusText(nextStatusRaw) + const prevStatus = normalizeRoomStatusText(lastKnownRoomStatusByKeyRef.current[keyLower]) + + // unknown niemals melden und auch nicht als letzten echten Status überschreiben + if (nextStatus === 'unknown') return + + // keine Änderung + if (prevStatus === nextStatus) return + + // keine "erste Sichtung" als Wechsel melden + const hadPreviousRealStatus = + Boolean(lastKnownRoomStatusByKeyRef.current[keyLower]) && + prevStatus !== 'unknown' + + lastKnownRoomStatusByKeyRef.current[keyLower] = nextStatus + + if (!hadPreviousRealStatus) return + + const displayName = + String(model?.modelKey ?? keyLower).trim() || keyLower + + const imageUrl = + String(patch?.imageUrl ?? '').trim() || + String(model?.imageUrl ?? '').trim() || + undefined + + const title = + nextStatus === 'public' + ? `${displayName} ist live` + : `${displayName} Status geändert` + + const message = `Status: ${roomStatusLabelDE(prevStatus)} → ${roomStatusLabelDE(nextStatus)}` + + notifyRef.current?.info(title, message, { + imageUrl, + imageAlt: displayName, + durationMs: 3000, + onClick: () => { + window.dispatchEvent( + new CustomEvent('open-model-details', { + detail: { modelKey: keyLower }, + }) + ) + }, + }) + }, []) + + useEffect(() => { + onModelJobEventRef.current = (ev: MessageEvent) => { + try { + const msg = JSON.parse(String(ev.data ?? 'null')) as JobEvent + const modelKey = String(msg?.model ?? '').trim().toLowerCase() + + if (msg?.type === 'job_upsert') { + if (modelKey) { + const statusLower = String(msg?.status ?? '').trim().toLowerCase() + const phaseLower = String(msg?.phase ?? '').trim().toLowerCase() + + const isActiveVisibleRecording = + statusLower === 'running' && + (phaseLower === '' || phaseLower === 'recording') + + if (isActiveVisibleRecording) { + removePendingAutoStart(modelKey) + } + + const roomStatus = String(msg?.roomStatus ?? '').trim().toLowerCase() + + if (roomStatus) { + const isOnline = + typeof msg?.isOnline === 'boolean' + ? Boolean(msg.isOnline) + : roomStatus !== 'offline' + + maybeNotifyWatchedModelStatusChange(modelKey, roomStatus, { + imageUrl: String(msg?.modelImageUrl ?? '').trim(), + }) + + applyRoomStateToModel(modelKey, { + roomStatus, + isOnline, + imageUrl: String(msg?.modelImageUrl ?? '').trim(), + chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(), + }) + } + } + } + + queueJobDelta(msg) + } catch { + // ignore + } + } + }, [applyRoomStateToModel, maybeNotifyWatchedModelStatusChange, queueJobDelta, removePendingAutoStart]) + + useEffect(() => { + const onOpen = (ev: Event) => { + const e = ev as CustomEvent<{ modelKey?: string }> + const raw0 = (e.detail?.modelKey ?? '').trim() + if (!raw0) return + + // 1) Wenn es "nur ein Key" ist (z.B. maypeach), direkt übernehmen + // Heuristik: keine Spaces, keine Slashes -> sehr wahrscheinlich Key + const looksLikeKey = + !raw0.includes(' ') && + !raw0.includes('/') && + !raw0.includes('\\') + + if (looksLikeKey) { + const k = raw0.replace(/^@/, '').trim().toLowerCase() + if (k) setDetailsModelKey(k) + return + } + + // 2) Sonst: URL/Path normalisieren + Provider-Key extrahieren + const norm0 = normalizeHttpUrl(raw0) + if (!norm0) { + // Fallback auf alte Key-Logik (falls raw sowas wie "chaturbate.com/im_jasmine" ist) + let k = raw0.replace(/^https?:\/\//i, '') + if (k.includes('/')) k = k.split('/').filter(Boolean).pop() || k + if (k.includes(':')) k = k.split(':').pop() || k + k = k.trim().toLowerCase() + if (k) setDetailsModelKey(k) + return + } + + const norm = canonicalizeProviderUrl(norm0) + const keyLower = providerKeyLowerFromUrl(norm) + + if (keyLower) setDetailsModelKey(keyLower) + } + + window.addEventListener('open-model-details', onOpen as any) + return () => window.removeEventListener('open-model-details', onOpen as any) + }, []) + + useEffect(() => { + const onOpen = (ev: Event) => { + const e = ev as CustomEvent<{ jobId?: string; output?: string }> + const jobId = String(e.detail?.jobId ?? '').trim() + const output = String(e.detail?.output ?? '').trim() + + let hit: RecordJob | null = null + + if (jobId) { + hit = + jobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? + doneJobs.find((j) => String((j as any)?.id ?? '').trim() === jobId) ?? + null + } + + if (!hit && output) { + const wanted = baseName(output) + hit = + jobs.find((j) => baseName(j.output || '') === wanted) ?? + doneJobs.find((j) => baseName(j.output || '') === wanted) ?? + null + } + + if (hit) { + setSplitJob(hit) + setSplitModalKey((k) => k + 1) + setSplitModalOpen(true) + } + } + + window.addEventListener('open-video-splitter', onOpen as EventListener) + return () => window.removeEventListener('open-video-splitter', onOpen as EventListener) + }, [jobs, doneJobs]) + + const upsertModelCache = useCallback((m: StoredModel) => { + const now = Date.now() + const cur = modelsCacheRef.current + if (!cur) { + modelsCacheRef.current = { ts: now, list: [m] } + return + } + + cur.ts = now + const idx = cur.list.findIndex((x) => x.id === m.id) + + if (idx >= 0) { + cur.list[idx] = mergeStoredModel(cur.list[idx], m) + } else { + cur.list.unshift(m) + } + }, []) + + const recSettingsRef = useRef(recSettings) + useEffect(() => { + recSettingsRef.current = recSettings + }, [recSettings]) + + const [autostartState, setAutostartState] = useState({ + paused: false, + pausedByUser: false, + pausedByDisk: false, + }) + + useEffect(() => { + busyRef.current = busy + }, [busy]) + useEffect(() => { + cookiesRef.current = cookies + }, [cookies]) + useEffect(() => { + jobsRef.current = jobs + }, [jobs]) + + // pending start falls gerade busy + const lastClipboardUrlRef = useRef('') + + const chaturbateStoreKeysLower = useMemo(() => { + const set = new Set() + for (const m of Object.values(modelsByKey)) { + if (!isChaturbateStoreModel(m)) continue + const k = lower(String(m?.modelKey ?? '')) + if (k) set.add(k) + } + return Array.from(set) + }, [modelsByKey, isChaturbateStoreModel]) + + const watchedModelKeysLower = useMemo(() => { + const set = new Set() + + for (const m of Object.values(modelsByKey)) { + if (!m?.watching) continue + + const k = lower(String(m?.modelKey ?? '')) + if (!k) continue + + set.add(k) + } + + return Array.from(set) + }, [modelsByKey]) + + function candidateModelKeysFromJob(job: RecordJob): string[] { + const anyJob = job as any + + const rawSource = + String(anyJob?.sourceUrl ?? anyJob?.SourceURL ?? '').trim() + + const norm0 = normalizeHttpUrl(rawSource) + const norm = norm0 ? canonicalizeProviderUrl(norm0) : '' + const keyFromUrl = norm ? providerKeyLowerFromUrl(norm) : '' + + const candidates = [ + anyJob?.model, + anyJob?.modelKey, + anyJob?.meta?.modelKey, + anyJob?.meta?.model?.key, + anyJob?.meta?.model?.name, + keyFromUrl, + modelKeyFromFilename(anyJob?.output || ''), + ] + + const seen = new Set() + const out: string[] = [] + + for (const value of candidates) { + const k = String(value ?? '').trim().toLowerCase() + if (!k || seen.has(k)) continue + seen.add(k) + out.push(k) + } + + return out + } + + const requiredModelKeys = useMemo(() => { + const set = new Set() + + for (const j of jobs) { + for (const k of candidateModelKeysFromJob(j)) { + set.add(k) + } + } + + for (const j of doneJobs) { + for (const k of candidateModelKeysFromJob(j)) { + set.add(k) + } + } + + for (const p of pendingWatchedRooms) { + const k = String(p?.modelKey ?? '').trim().toLowerCase() + if (k) set.add(k) + } + + if (playerJob) { + for (const k of candidateModelKeysFromJob(playerJob)) { + set.add(k) + } + } + + if (detailsModelKey) { + const k = String(detailsModelKey ?? '').trim().toLowerCase() + if (k) set.add(k) + } + + return Array.from(set).sort() + }, [jobs, doneJobs, pendingWatchedRooms, playerJob, detailsModelKey]) + + const requiredModelKeysSig = useMemo( + () => requiredModelKeys.join('|'), + [requiredModelKeys] + ) + + const requiredModelKeysRef = useRef(requiredModelKeys) + + useEffect(() => { + requiredModelKeysRef.current = requiredModelKeys + }, [requiredModelKeys]) + + const lastOverviewSigRef = useRef(null) + + useEffect(() => { + if (!authed) return + + const scheduleOverviewLoad = () => { + if (overviewTimerRef.current != null) { + window.clearTimeout(overviewTimerRef.current) + } + + overviewTimerRef.current = window.setTimeout(() => { + void loadAppModelsSnapshot(requiredModelKeys) + }, 250) + } + + if (lastOverviewSigRef.current !== requiredModelKeysSig) { + lastOverviewSigRef.current = requiredModelKeysSig + scheduleOverviewLoad() + } + + const onChanged = (ev: Event) => { + const e = ev as CustomEvent + const detail = e?.detail ?? {} + const updated = detail?.model + + if (updated && typeof updated === 'object') { + const k = String(updated.modelKey ?? '').toLowerCase().trim() + + const mergedUpdated = k + ? mergeStoredModel(modelsByKeyRef.current[k], updated) + : mergeStoredModel(undefined, updated) + + if (k) { + setModelsByKey((prev) => ({ + ...prev, + [k]: mergeStoredModel(prev[k], updated), + })) + } + + try { + upsertModelCache(mergedUpdated) + } catch {} + + setPlayerModel((prev) => + prev?.id === updated.id ? mergeStoredModel(prev, updated) : prev + ) + + setPlayerModel((prev) => + prev?.id === updated.id ? mergeStoredModel(prev, updated) : prev + ) + scheduleOverviewLoad() + return + } + + if (detail?.removed) { + const removedId = String(detail?.id ?? '').trim() + const removedKey = String(detail?.modelKey ?? '').toLowerCase().trim() + + if (removedKey) { + setModelsByKey((prev) => { + const { [removedKey]: _drop, ...rest } = prev + return rest + }) + } + + if (removedId) { + setPlayerModel((prev) => (prev?.id === removedId ? null : prev)) + } + + scheduleOverviewLoad() + return + } + + scheduleOverviewLoad() + } + + window.addEventListener('models-changed', onChanged as any) + + return () => { + window.removeEventListener('models-changed', onChanged as any) + + if (overviewTimerRef.current != null) { + window.clearTimeout(overviewTimerRef.current) + overviewTimerRef.current = null + } + } + }, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) + useEffect(() => { donePageRef.current = donePage }, [donePage]) @@ -2848,8 +2811,10 @@ export default function App() { function isChaturbate(raw: string): boolean { const norm = normalizeHttpUrl(raw) if (!norm) return false + try { - return new URL(norm).hostname.includes('chaturbate.com') + const host = new URL(norm).hostname.replace(/^www\./i, '').toLowerCase() + return host === 'chaturbate.com' || host.endsWith('.chaturbate.com') } catch { return false } @@ -2939,14 +2904,22 @@ export default function App() { setPlayerModel(hit ?? null) }, [playerJob, modelsByKey]) + const manualStartInFlightRef = useRef(false) + async function onStart() { - const ok = await startUrl(sourceUrl, { silent: false, immediate: true }) + if (manualStartInFlightRef.current) return false - if (ok) { - setSourceUrl('') + manualStartInFlightRef.current = true + setBusy(true) + + try { + const ok = await startUrl(sourceUrl, { silent: false, immediate: true }) + if (ok) setSourceUrl('') + return ok + } finally { + manualStartInFlightRef.current = false + setBusy(false) } - - return ok } const handleAddToDownloads = useCallback( @@ -3081,6 +3054,12 @@ export default function App() { }) }, [donePage, donePageSize, doneSort]) + const doneCountRef = useRef(doneCount) + + useEffect(() => { + doneCountRef.current = doneCount + }, [doneCount]) + const handleDeleteJobWithUndo = useCallback( async (job: RecordJob): Promise => { const file = baseName(job.output || '') @@ -3099,11 +3078,10 @@ export default function App() { window.setTimeout(() => { fillDonePageFromPrefetch({ removedFile: file }) - setDoneCount((c) => Math.max(0, c - 1)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) - const nextDoneCount = Math.max(0, doneCount - 1) + const nextDoneCount = Math.max(0, doneCountRef.current - 1) const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize) if (donePage < totalPagesAfterDelete) { @@ -3136,30 +3114,24 @@ export default function App() { ) const handleKeepJob = useCallback( - async (job: RecordJob): Promise => { + async (job: RecordJob): Promise => { const file = baseName(job.output || '') if (!file) return try { - await runFinishedFileAction({ + const data = await runFinishedFileAction<{ newFile?: string }>({ kind: 'keep', file, - run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), + run: () => + apiJSON<{ newFile?: string }>( + `/api/record/keep?file=${encodeURIComponent(file)}`, + { method: 'POST' } + ), onSuccess: async () => { window.setTimeout(() => { fillDonePageFromPrefetch({ removedFile: file }) - setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) - setDoneCount((c) => Math.max(0, c - 1)) - - const nextDoneCount = Math.max(0, doneCount - 1) - const totalPagesAfterKeep = getDoneTotalPages(nextDoneCount, donePageSize) - - if (donePage < totalPagesAfterKeep) { - void prefetchDonePage(donePage + 1) - } - void loadDoneCount() }, 320) }, @@ -3167,19 +3139,13 @@ export default function App() { notify.error('Keep fehlgeschlagen', file) }, }) + + return data } catch { return } }, - [ - runFinishedFileAction, - notify, - fillDonePageFromPrefetch, - donePage, - prefetchDonePage, - loadDoneCount, - doneCount, - ] + [runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount] ) const handleToggleHot = useCallback( @@ -3341,10 +3307,22 @@ export default function App() { } const k = lower(updated.modelKey || modelKey) - if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) - upsertModelCache(updated) - if (sameAsPlayer) setPlayerModel(updated) - window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) + const mergedUpdated = k + ? mergeStoredModel(modelsByKeyRef.current[k], updated) + : updated + + if (k) { + setModelsByKey((p) => ({ + ...p, + [k]: mergeStoredModel(p[k], updated), + })) + } + + upsertModelCache(mergedUpdated) + if (sameAsPlayer) setPlayerModel(mergedUpdated) + window.dispatchEvent( + new CustomEvent('models-changed', { detail: { model: mergedUpdated } }) + ) } catch (e: any) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) @@ -3424,10 +3402,22 @@ export default function App() { } const k = lower(updated.modelKey || modelKey) - if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) - upsertModelCache(updated) - if (sameAsPlayer) setPlayerModel(updated) - window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) + const mergedUpdated = k + ? mergeStoredModel(modelsByKeyRef.current[k], updated) + : updated + + if (k) { + setModelsByKey((p) => ({ + ...p, + [k]: mergeStoredModel(p[k], updated), + })) + } + + upsertModelCache(mergedUpdated) + if (sameAsPlayer) setPlayerModel(mergedUpdated) + window.dispatchEvent( + new CustomEvent('models-changed', { detail: { model: mergedUpdated } }) + ) } catch (e: any) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) @@ -3505,10 +3495,22 @@ export default function App() { } const k = lower(updated.modelKey || modelKey) - if (k) setModelsByKey((p) => ({ ...p, [k]: updated })) - upsertModelCache(updated) - if (sameAsPlayer) setPlayerModel(updated) - window.dispatchEvent(new CustomEvent('models-changed', { detail: { model: updated } })) + const mergedUpdated = k + ? mergeStoredModel(modelsByKeyRef.current[k], updated) + : updated + + if (k) { + setModelsByKey((p) => ({ + ...p, + [k]: mergeStoredModel(p[k], updated), + })) + } + + upsertModelCache(mergedUpdated) + if (sameAsPlayer) setPlayerModel(mergedUpdated) + window.dispatchEvent( + new CustomEvent('models-changed', { detail: { model: mergedUpdated } }) + ) } catch (e: any) { setModelsByKey((p) => ({ ...p, [modelKey]: prev })) upsertModelCache(prev) @@ -3600,6 +3602,9 @@ export default function App() { const headerStatBadgeClass = 'inline-flex h-6 items-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-2 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100 sm:h-7 sm:gap-1.5 sm:px-2.5 sm:text-[11px]' + const mobileHeaderStatBadgeClass = + 'inline-flex h-6 min-w-[3.1rem] items-center justify-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-1.5 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100' + return (
+ + {activeTags.length > 0 ? ( + + ) : null} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/finishedModelFlags.ts b/frontend/src/components/ui/finishedModelFlags.ts new file mode 100644 index 0000000..468d6d5 --- /dev/null +++ b/frontend/src/components/ui/finishedModelFlags.ts @@ -0,0 +1,143 @@ +// frontend\src\components\ui\finishedSelectionStore.ts + +'use client' + +import type { + RecordJob, + GalleryModelFlags, +} from '../../types' + +function cleanString(value: unknown): string { + return String(value ?? '').trim() +} + +function parseMetaObject(metaRaw: unknown): Record | null { + if (!metaRaw) return null + + if (typeof metaRaw === 'string') { + try { + const parsed = JSON.parse(metaRaw) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } + } + + return typeof metaRaw === 'object' && !Array.isArray(metaRaw) + ? (metaRaw as Record) + : null +} + +export function parseFinishedTags(value?: unknown): string[] { + if (Array.isArray(value)) { + const seen = new Set() + const out: string[] = [] + + for (const item of value) { + const s = cleanString(item) + if (!s) continue + + const key = s.toLowerCase() + if (seen.has(key)) continue + + seen.add(key) + out.push(s) + } + + return out + } + + const raw = cleanString(value) + if (!raw) return [] + + const parts = raw + .split(/[\n,;|]+/g) + .map((p) => p.trim()) + .filter(Boolean) + + const seen = new Set() + const out: string[] = [] + + for (const part of parts) { + const key = part.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(part) + } + + return out +} + +function candidateModelNames( + job: RecordJob, + modelNameFromOutput: (output?: string) => string +): string[] { + const anyJob = job as any + const meta = parseMetaObject(anyJob?.meta) + + const rawCandidates = [ + modelNameFromOutput(job.output), + anyJob?.model, + anyJob?.modelName, + anyJob?.performer, + anyJob?.creator, + meta?.model?.name, + meta?.modelName, + meta?.performer, + meta?.creator, + meta?.source?.modelName, + ] + + const seen = new Set() + const out: string[] = [] + + for (const value of rawCandidates) { + const s = cleanString(value) + if (!s) continue + + const key = s.toLowerCase() + if (seen.has(key)) continue + + seen.add(key) + out.push(s) + } + + return out +} + +export function resolveFinishedModelFlags( + job: RecordJob, + modelsByKey: Record, + modelNameFromOutput: (output?: string) => string, + lower: (s: string) => string +): { + model: string + modelKey: string + flags?: GalleryModelFlags +} { + const candidates = candidateModelNames(job, modelNameFromOutput) + + for (const candidate of candidates) { + const key = lower(candidate) + const flags = modelsByKey[key] + + if (flags) { + return { + model: candidate, + modelKey: key, + flags, + } + } + } + + const fallbackModel = candidates[0] ?? modelNameFromOutput(job.output) ?? '—' + const fallbackKey = lower(cleanString(fallbackModel)) + + return { + model: fallbackModel, + modelKey: fallbackKey, + flags: modelsByKey[fallbackKey], + } +} \ No newline at end of file diff --git a/frontend/src/components/ui/tagUtils.ts b/frontend/src/components/ui/tagUtils.ts new file mode 100644 index 0000000..79f3344 --- /dev/null +++ b/frontend/src/components/ui/tagUtils.ts @@ -0,0 +1,24 @@ +export const lowerTag = (s: string) => String(s ?? '').trim().toLowerCase() + +export function parseTagList(raw?: string): string[] { + const s = String(raw ?? '').trim() + if (!s) return [] + + const parts = s + .split(/[\n,;|]+/g) + .map((p) => p.trim()) + .filter(Boolean) + + const seen = new Set() + const out: string[] = [] + + for (const p of parts) { + const k = p.toLowerCase() + if (seen.has(k)) continue + seen.add(k) + out.push(p) + } + + out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + return out +} \ No newline at end of file diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bf2ca96..ffecc8d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -229,8 +229,6 @@ export type StoredModelFlags = { watching?: boolean | null tags?: string gender?: string | null - sex?: string | null - subgender?: string | null } export type GalleryModelFlags = StoredModelFlags & { @@ -307,9 +305,9 @@ export type StoredModel = { watching: boolean favorite?: boolean liked?: boolean | null + tags?: string isUrl?: boolean path?: string - roomStatus?: string isOnline?: boolean chatRoomUrl?: string