This commit is contained in:
Chris 2026-03-06 17:28:07 +01:00
parent 0fac07f620
commit b17a45e1d3
15 changed files with 766 additions and 575 deletions

View File

@ -452,7 +452,16 @@ func startChaturbateOnlinePoller(store *ModelStore) {
continue continue
} }
_ = cbApplySnapshot(rooms) fetchedAtNow := cbApplySnapshot(rooms)
// ✅ Alle bekannten Chaturbate-Models in der DB mit aktuellem Online-Snapshot synchronisieren
if cbModelStore != nil {
go func(roomsCopy []ChaturbateRoom, fetchedAt time.Time) {
if err := cbModelStore.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt); err != nil && verboseLogs() {
fmt.Println("⚠️ [chaturbate] sync known models failed:", err)
}
}(append([]ChaturbateRoom(nil), rooms...), fetchedAtNow)
}
// Tags übernehmen ist teuer -> nur selten + im Hintergrund // Tags übernehmen ist teuer -> nur selten + im Hintergrund
if cbModelStore != nil && len(rooms) > 0 { if cbModelStore != nil && len(rooms) > 0 {
@ -941,11 +950,15 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
_ = cbApplySnapshot(rooms) fetchedAtNow := cbApplySnapshot(rooms)
if cbModelStore != nil && len(rooms) > 0 { if cbModelStore != nil {
_ = cbModelStore.SyncChaturbateOnlineForKnownModels(rooms, fetchedAtNow)
if len(rooms) > 0 {
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms) cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
} }
}
}() }()
} }
} }

View File

@ -1036,6 +1036,176 @@ WHERE lower(trim(host)) = lower(trim($1));
return out, nil return out, nil
} }
func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom, fetchedAt time.Time) error {
if err := s.ensureInit(); err != nil {
return err
}
const host = "chaturbate.com"
fetchedAt = fetchedAt.UTC()
knownKeys, err := s.ListModelKeysByHost(host)
if err != nil {
return err
}
if len(knownKeys) == 0 {
return nil
}
roomsByUser := indexRoomsByUser(rooms)
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// q1 mit cb_online_last_error
stmt1, err1 := tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_json = $3,
cb_online_fetched_at = $4,
cb_online_last_error = $5,
profile_image_url = CASE
WHEN COALESCE(trim($6), '') <> '' THEN $6
ELSE profile_image_url
END,
profile_image_updated_at = CASE
WHEN COALESCE(trim($6), '') <> '' THEN $4
ELSE profile_image_updated_at
END,
updated_at = $7
WHERE lower(trim(host)) = lower(trim($8))
AND lower(trim(model_key)) = lower(trim($9));
`)
// fallback ohne cb_online_last_error
var stmt2 *sql.Stmt
if err1 != nil {
stmt2, err = tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_json = $3,
cb_online_fetched_at = $4,
profile_image_url = CASE
WHEN COALESCE(trim($5), '') <> '' THEN $5
ELSE profile_image_url
END,
profile_image_updated_at = CASE
WHEN COALESCE(trim($5), '') <> '' THEN $4
ELSE profile_image_updated_at
END,
updated_at = $6
WHERE lower(trim(host)) = lower(trim($7))
AND lower(trim(model_key)) = lower(trim($8));
`)
if err != nil {
return err
}
defer stmt2.Close()
} else {
defer stmt1.Close()
}
now := time.Now().UTC()
fetchedAtStr := fetchedAt.Format(time.RFC3339Nano)
for _, key := range knownKeys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
var (
online bool
snap *ChaturbateOnlineSnapshot
jsonStr string
imgURL string
)
if rm, ok := roomsByUser[strings.ToLower(key)]; ok {
online = true
imgURL = strings.TrimSpace(selectBestRoomImageURL(rm))
snap = &ChaturbateOnlineSnapshot{
Username: rm.Username,
DisplayName: rm.DisplayName,
CurrentShow: strings.TrimSpace(rm.CurrentShow),
RoomSubject: rm.RoomSubject,
Location: rm.Location,
Country: rm.Country,
SpokenLanguages: rm.SpokenLanguages,
Gender: rm.Gender,
NumUsers: rm.NumUsers,
NumFollowers: rm.NumFollowers,
IsHD: rm.IsHD,
IsNew: rm.IsNew,
Age: rm.Age,
SecondsOnline: rm.SecondsOnline,
ImageURL: rm.ImageURL,
ImageURL360: rm.ImageURL360,
ChatRoomURL: rm.ChatRoomURL,
ChatRoomURLRS: rm.ChatRoomURLRS,
Tags: rm.Tags,
}
} else {
online = false
snap = &ChaturbateOnlineSnapshot{
Username: key,
CurrentShow: "offline",
}
}
if snap != nil {
if b, err := json.Marshal(snap); err == nil {
jsonStr = strings.TrimSpace(string(b))
}
}
if stmt1 != nil {
if _, err := stmt1.Exec(
online,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
"",
imgURL,
now,
host,
key,
); err != nil {
return err
}
} else {
if _, err := stmt2.Exec(
online,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
imgURL,
now,
host,
key,
); err != nil {
return err
}
}
_ = fetchedAtStr // falls du später Logging willst
}
return tx.Commit()
}
func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr string) error { func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr string) error {
if err := s.ensureInit(); err != nil { if err := s.ensureInit(); err != nil {
return err return err

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

@ -5,8 +5,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, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-C4whm-WW.js"></script> <script type="module" crossorigin src="/assets/index-jlVIND2Y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3IFBscEU.css"> <link rel="stylesheet" crossorigin href="/assets/index-D0pbgV48.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -107,12 +107,6 @@ type ChaturbateOnlineRoom = {
image_url?: string image_url?: string
} }
type ChaturbateOnlineResponse = {
enabled: boolean
rooms: ChaturbateOnlineRoom[]
total?: number
}
function normalizeHttpUrl(raw: string): string | null { function normalizeHttpUrl(raw: string): string | null {
let v = (raw ?? '').trim() let v = (raw ?? '').trim()
if (!v) return null if (!v) return null
@ -943,17 +937,6 @@ export default function App() {
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>>({})
const lastCbShowByKeyLowerRef = useRef<Record<string, string>>({})
// ✅ merkt sich, ob ein Model im letzten Snapshot überhaupt online war
const lastCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})
// ✅ verhindert Toast-Spam direkt beim ersten Poll (Startup)
const cbOnlineInitDoneRef = useRef(false)
// ✅ merkt sich, ob ein Model seit App-Start schon einmal online war
const everCbOnlineByKeyLowerRef = useRef<Record<string, true>>({})
useEffect(() => { useEffect(() => {
cbOnlineByKeyLowerRef.current = cbOnlineByKeyLower cbOnlineByKeyLowerRef.current = cbOnlineByKeyLower
}, [cbOnlineByKeyLower]) }, [cbOnlineByKeyLower])

View File

@ -76,6 +76,60 @@ const pendingRowKey = (p: PendingWatchedRoom) => {
return String(anyP.key ?? anyP.id ?? pendingModelName(p)) return String(anyP.key ?? anyP.id ?? pendingModelName(p))
} }
const normalizeRoomStatus = (v: unknown): string => {
const s = String(v ?? '').trim().toLowerCase()
switch (s) {
case 'public':
return 'Public'
case 'private':
return 'Private'
case 'hidden':
return 'Hidden'
case 'away':
return 'Away'
case 'offline':
return 'Offline'
default:
return 'Offline'
}
}
const roomStatusOfJob = (job: RecordJob): string => {
const j = job as any
const raw =
j.currentShow ??
j.roomStatus ??
j.modelStatus ??
j.show ??
j.statusText ??
j.model?.currentShow ??
j.model?.roomStatus ??
j.model?.status ??
j.room?.currentShow ??
j.room?.status ??
''
return normalizeRoomStatus(raw)
}
const roomStatusTone = (status: string): string => {
switch (status) {
case 'Public':
return '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'
case 'Private':
return 'bg-fuchsia-500/15 text-fuchsia-900 ring-fuchsia-500/30 dark:bg-fuchsia-400/10 dark:text-fuchsia-200 dark:ring-fuchsia-400/25'
case 'Hidden':
return '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'
case 'Away':
return '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'
case 'Offline':
default:
return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10'
}
}
const toMs = (v: unknown): number => { const toMs = (v: unknown): number => {
if (typeof v === 'number' && Number.isFinite(v)) { if (typeof v === 'number' && Number.isFinite(v)) {
// Heuristik: 10-stellige Unix-Sekunden -> ms // Heuristik: 10-stellige Unix-Sekunden -> ms
@ -317,7 +371,7 @@ function DownloadsCardRow({
const p = r.pending const p = r.pending
const name = pendingModelName(p) const name = pendingModelName(p)
const url = pendingUrl(p) const url = pendingUrl(p)
const show = (p.currentShow || 'unknown').toLowerCase() const show = normalizeRoomStatus(p.currentShow)
return ( return (
<div <div
@ -410,12 +464,14 @@ function DownloadsCardRow({
const phaseLower = phase.toLowerCase() const phaseLower = phase.toLowerCase()
const isRecording = phaseLower === 'recording' const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur für Stop-Button/UI
const rawStatus = String(j.status ?? '').toLowerCase() const rawStatus = String(j.status ?? '').toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
const roomStatus = roomStatusOfJob(j)
let phaseText = phase ? (phaseLabel(phase) || phase) : '' let phaseText = phase ? (phaseLabel(phase) || phase) : ''
if (phaseLower === 'recording') { if (phaseLower === 'recording') {
@ -424,8 +480,7 @@ function DownloadsCardRow({
phaseText = postWorkLabel(j, postworkInfoOf(j)) phaseText = postWorkLabel(j, postworkInfoOf(j))
} }
const statusText = rawStatus || 'unknown' const progressLabel = phaseText || roomStatus
const progressLabel = phaseText || statusText
const progress = Number((j as any).progress ?? 0) const progress = Number((j as any).progress ?? 0)
const showBar = const showBar =
@ -504,13 +559,12 @@ function DownloadsCardRow({
{ /* Status-Badge */} { /* Status-Badge */}
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
'bg-gray-900/5 text-gray-800 dark:bg-white/10 dark:text-gray-200', roomStatusTone(roomStatus),
isStopping ? 'ring-1 ring-amber-500/30' : 'ring-1 ring-emerald-500/25',
].join(' ')} ].join(' ')}
title={statusText} title={roomStatus}
> >
{statusText} {roomStatus}
</span> </span>
</div> </div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}> <div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300" title={j.output}>
@ -1052,21 +1106,7 @@ export default function Downloads({
const f = baseName(j.output || '') const f = baseName(j.output || '')
const name = modelNameFromOutput(j.output) const name = modelNameFromOutput(j.output)
const rawStatus = String(j.status ?? '').toLowerCase() const roomStatus = roomStatusOfJob(j)
// Final "stopped" sauber erkennen (inkl. UI-Stop)
const isStopRequested = Boolean(stopRequestedIds[j.id])
const stopInitiated = Boolean(stopInitiatedIds[j.id])
const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt))
// ✅ Status-Text neben dem Modelname: NUR Job-Status
// (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 (
<> <>
@ -1077,21 +1117,11 @@ export default function Downloads({
<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',
rawStatus === 'running' roomStatusTone(roomStatus),
? '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'
: isStoppedFinal
? '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'
: rawStatus === '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'
: rawStatus === 'finished'
? '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'
: 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={badgeText} title={roomStatus}
> >
{badgeText} {roomStatus}
</span> </span>
</div> </div>
<span className="block max-w-[220px] truncate" title={j.output}> <span className="block max-w-[220px] truncate" title={j.output}>
@ -1157,7 +1187,7 @@ export default function Downloads({
} }
const p = r.pending const p = r.pending
const show = (p.currentShow || 'unknown').toLowerCase() const show = normalizeRoomStatus(p.currentShow)
return ( return (
<div className="min-w-0"> <div className="min-w-0">

View File

@ -13,6 +13,7 @@ type ModalProps = {
open: boolean open: boolean
onClose: () => void onClose: () => void
title?: string title?: string
titleRight?: ReactNode
children?: ReactNode children?: ReactNode
footer?: ReactNode footer?: ReactNode
icon?: ReactNode icon?: ReactNode
@ -81,6 +82,7 @@ export default function Modal({
open, open,
onClose, onClose,
title, title,
titleRight,
children, children,
footer, footer,
icon, icon,
@ -212,7 +214,7 @@ export default function Modal({
layout === 'split' ? 'hidden lg:flex' : 'flex' layout === 'split' ? 'hidden lg:flex' : 'flex'
)} )}
> >
<div className="min-w-0"> <div className="min-w-0 flex-1">
{title ? ( {title ? (
<Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate"> <Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate">
{title} {title}
@ -220,6 +222,9 @@ export default function Modal({
) : null} ) : null}
</div> </div>
<div className="shrink-0 flex items-center gap-2">
{titleRight ? <div className="hidden sm:block">{titleRight}</div> : null}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@ -235,6 +240,7 @@ export default function Modal({
<XMarkIcon className="size-5" /> <XMarkIcon className="size-5" />
</button> </button>
</div> </div>
</div>
{/* Body */} {/* Body */}
{layout === 'single' ? ( {layout === 'single' ? (
@ -286,7 +292,7 @@ export default function Modal({
mobileCollapsed ? 'py-2' : 'py-3' mobileCollapsed ? 'py-2' : 'py-3'
)} )}
> >
<div className="min-w-0 flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2 flex-1">
{mobileCollapsedImageSrc ? ( {mobileCollapsedImageSrc ? (
<img <img
src={mobileCollapsedImageSrc} src={mobileCollapsedImageSrc}
@ -300,7 +306,7 @@ export default function Modal({
/> />
) : null} ) : null}
<div className="min-w-0"> <div className="min-w-0 flex-1">
{title ? ( {title ? (
<div <div
className={cn( className={cn(
@ -314,6 +320,9 @@ export default function Modal({
</div> </div>
</div> </div>
<div className="shrink-0 flex items-center gap-2">
{titleRight ? <div>{titleRight}</div> : null}
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@ -329,6 +338,7 @@ export default function Modal({
<XMarkIcon className="size-5" /> <XMarkIcon className="size-5" />
</button> </button>
</div> </div>
</div>
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */} {/* Sticky tabs/actions (always sticky because in this sticky wrapper) */}
{rightHeader ? <div>{rightHeader}</div> : null} {rightHeader ? <div>{rightHeader}</div> : null}

View File

@ -170,7 +170,7 @@ export default function Pagination({
return ( return (
<div <div
className={clsx( className={clsx(
'flex items-center justify-between bg-white dark:border-white/10 dark:bg-transparent', 'flex items-center justify-between bg-transparent dark:border-white/10',
className className
)} )}
> >

View File

@ -417,7 +417,7 @@ export default function Player({
// ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben // ✅ Live-Stream URL (Playback) -> play=1 hält Preview sicher am Leben
const liveHlsSrc = React.useMemo( const liveHlsSrc = React.useMemo(
() => apiUrl(`/api/preview?id=${encodeURIComponent(previewId)}&play=1&file=index_hq.m3u8`), () => apiUrl(`/api/preview/live?id=${encodeURIComponent(previewId)}&play=1`),
[previewId] [previewId]
) )
@ -1213,7 +1213,7 @@ export default function Player({
if (typeof window === 'undefined') return r if (typeof window === 'undefined') return r
const ratio = getVideoAspectRatio() const ratio = getVideoAspectRatio()
const BAR_H = 30 // gewünschter fixer Platz unter dem Video für die Controlbar const BAR_H = isRunning ? 0 : 30 // gewünschter fixer Platz unter dem Video für die Controlbar
const { w: vw, h: vh } = getViewport() const { w: vw, h: vh } = getViewport()
const maxW = vw - MARGIN * 2 const maxW = vw - MARGIN * 2
@ -1248,7 +1248,7 @@ export default function Player({
return { x, y, w: Math.round(w), h } return { x, y, w: Math.round(w), h }
}, },
[getVideoAspectRatio] [getVideoAspectRatio, isRunning]
) )
const loadRect = React.useCallback(() => { const loadRect = React.useCallback(() => {

View File

@ -10,7 +10,6 @@ import TaskList from './TaskList'
import type { TaskItem } from './TaskList' import type { TaskItem } from './TaskList'
import PostgresUrlModal from './PostgresUrlModal' import PostgresUrlModal from './PostgresUrlModal'
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid' import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'
type RecorderSettings = { type RecorderSettings = {
databaseUrl?: string databaseUrl?: string

View File

@ -422,12 +422,14 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
transition: animMs ? `transform ${animMs}ms ease` : undefined, transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined, willChange: dx !== 0 ? 'transform' : undefined,
/*
boxShadow: boxShadow:
dx !== 0 dx !== 0
? swipeDir === 'right' ? swipeDir === 'right'
? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})` ? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})`
: `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})` : `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})`
: undefined, : undefined,
*/
borderRadius: dx !== 0 ? '12px' : undefined, borderRadius: dx !== 0 ? '12px' : undefined,
filter: filter:
dx !== 0 dx !== 0