This commit is contained in:
Chris 2026-03-06 16:59:51 +01:00
parent d578d4e6aa
commit 0fac07f620
7 changed files with 698 additions and 551 deletions

View File

@ -29,14 +29,14 @@ type Model struct {
Liked *bool `json:"liked"` // nil = keine Angabe
}
type modelStore struct {
type jsonModelStore struct {
mu sync.Mutex
path string
loaded bool
items []Model
}
var models = &modelStore{
var models = &jsonModelStore{
path: filepath.Join("data", "models.json"),
}

View File

@ -262,7 +262,12 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
modelsWriteJSON(w, http.StatusOK, store.List())
// ✅ Wenn du List() als ([]T, error) hast -> Fehler sichtbar machen:
// Falls List() aktuell nur []T zurückgibt, siehe Schritt 2 unten.
list := store.List()
modelsWriteJSON(w, http.StatusOK, list)
})
// ✅ Profilbild-Blob aus DB ausliefern

View File

@ -3,7 +3,9 @@ package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
@ -25,6 +27,11 @@ type StoredModel struct {
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
// ✅ Chaturbate Online Snapshot (persistiert aus chaturbate_online.go)
CbOnlineJSON string `json:"cbOnlineJson,omitempty"`
CbOnlineFetchedAt string `json:"cbOnlineFetchedAt,omitempty"`
CbOnlineLastError string `json:"cbOnlineLastError,omitempty"`
ProfileImageURL string `json:"profileImageUrl,omitempty"`
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
@ -776,7 +783,7 @@ UPDATE models SET
-- last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
last_stream = CASE
WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6
WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
ELSE last_stream
END,
@ -784,7 +791,10 @@ UPDATE models SET
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
hot = CASE WHEN $9=true THEN true ELSE hot END,
keep = CASE WHEN $10=true THEN true ELSE keep END,
liked = CASE WHEN liked IS NULL AND $11 IS NOT NULL THEN $11 ELSE liked END,
liked = CASE
WHEN liked IS NULL AND $11::boolean IS NOT NULL THEN $11::boolean
ELSE liked
END,
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
WHERE id = $13;
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
return []StoredModel{}
}
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'')
rows, err := s.db.Query(`
q1 := `
SELECT
id,input,is_url,host,path,model_key,
tags, last_stream,
last_seen_online, last_seen_online_at,
id,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''), -- optional
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
@ -830,10 +849,42 @@ SELECT
created_at, updated_at
FROM models
ORDER BY updated_at DESC;
`)
`
q2 := `
SELECT
id,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
''::text as cb_online_last_error, -- fallback dummy
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
ORDER BY updated_at DESC;
`
rows, err := s.db.Query(q1)
if err != nil {
// ✅ genau dein Fall: "Spalte existiert nicht" -> fallback
fmt.Println("models List query err (q1):", err)
rows, err = s.db.Query(q2)
if err != nil {
fmt.Println("models List query err (q2):", err)
return []StoredModel{}
}
}
defer rows.Close()
out := make([]StoredModel, 0, 64)
@ -841,7 +892,6 @@ ORDER BY updated_at DESC;
for rows.Next() {
var (
id, input, host, path, modelKey, tags string
isURL bool
lastStream sql.NullTime
@ -849,6 +899,10 @@ ORDER BY updated_at DESC;
lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt sql.NullTime
hasProfileImage int64
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
&id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching,
Favorite: favorite,
Hot: hot,
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)}
}
type ChaturbateOnlineSnapshot struct {
Username string `json:"username"`
DisplayName string `json:"display_name,omitempty"`
CurrentShow string `json:"current_show,omitempty"` // public/private/hidden/away
RoomSubject string `json:"room_subject,omitempty"`
Location string `json:"location,omitempty"`
Country string `json:"country,omitempty"`
SpokenLanguages string `json:"spoken_languages,omitempty"`
Gender string `json:"gender,omitempty"`
NumUsers int `json:"num_users,omitempty"`
NumFollowers int `json:"num_followers,omitempty"`
IsHD bool `json:"is_hd,omitempty"`
IsNew bool `json:"is_new,omitempty"`
Age int `json:"age,omitempty"`
SecondsOnline int `json:"seconds_online,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ImageURL360 string `json:"image_url_360x270,omitempty"`
ChatRoomURL string `json:"chat_room_url,omitempty"`
ChatRoomURLRS string `json:"chat_room_url_revshare,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (s *ModelStore) ListModelKeysByHost(host string) ([]string, error) {
if err := s.ensureInit(); err != nil {
return nil, err
}
host = canonicalHost(host)
if host == "" {
return nil, errors.New("host fehlt")
}
rows, err := s.db.Query(`
SELECT model_key
FROM models
WHERE lower(trim(host)) = lower(trim($1));
`, host)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 128)
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
continue
}
k = strings.ToLower(strings.TrimSpace(k))
if k != "" {
out = append(out, k)
}
}
return out, nil
}
func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
var jsonStr string
if snap != nil {
b, err := json.Marshal(snap)
if err == nil {
jsonStr = strings.TrimSpace(string(b))
}
}
ft := parseRFC3339Nano(fetchedAt)
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
// NOTE: cb_online_last_error nur updaten, wenn Spalte existiert.
// Wenn du die optionale Spalte nicht anlegst: entferne die beiden Stellen.
_, err := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
cb_online_last_error=$3,
updated_at=$4
WHERE lower(trim(host)) = lower(trim($5))
AND lower(trim(model_key)) = lower(trim($6));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), strings.TrimSpace(lastErr), now, host, key)
if err != nil {
// falls cb_online_last_error nicht existiert -> fallback ohne die Spalte
_, err2 := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), now, host, key)
return err2
}
return nil
}
func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*ChaturbateOnlineSnapshot, string, bool, error) {
if err := s.ensureInit(); err != nil {
return nil, "", false, err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return nil, "", false, errors.New("host/modelKey fehlt")
}
var js sql.NullString
var fetchedAt sql.NullTime
err := s.db.QueryRow(`
SELECT cb_online_json, cb_online_fetched_at
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, key).Scan(&js, &fetchedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", false, nil
}
if err != nil {
return nil, "", false, err
}
raw := strings.TrimSpace(js.String)
if raw == "" {
return nil, fmtNullTime(fetchedAt), false, nil
}
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
return nil, fmtNullTime(fetchedAt), false, err
}
return &snap, fmtNullTime(fetchedAt), true, nil
}
func nullableStringArg(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil {
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
)
if hostFilter == "" {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true
ORDER BY updated_at DESC;
`)
} else {
rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true AND host = $1
ORDER BY updated_at DESC;
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt sql.NullTime
hasProfileImage int64
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
createdAt, updatedAt time.Time
)
err := s.db.QueryRow(`
// q1: mit optionaler Spalte cb_online_last_error
q1 := `
SELECT
input,is_url,host,path,model_key,
tags, last_stream,
last_seen_online, last_seen_online_at,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
@ -1222,19 +1455,62 @@ SELECT
created_at, updated_at
FROM models
WHERE id=$1;
`, id).Scan(
`
// q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
// Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
q2 := `
SELECT
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
'' as cb_online_last_error,
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
WHERE id=$1;
`
scan := func(q string) error {
return s.db.QueryRow(q, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
}
err := scan(q1)
if err != nil {
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
if errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
return StoredModel{}, err
// Fallback versuchen (typisch: "column cb_online_last_error does not exist")
err2 := scan(q2)
if err2 != nil {
// wenn fallback auch kein Row findet, sauber melden
if errors.Is(err2, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
// sonst ursprünglichen Fehler behalten? -> ich gebe hier err2 zurück, weil er meist aussagekräftiger ist.
return StoredModel{}, err2
}
}
m := StoredModel{
@ -1249,6 +1525,10 @@ WHERE id=$1;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching,
Favorite: favorite,
Hot: hot,

View File

@ -501,7 +501,7 @@ function DownloadsCardRow({
<div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
{name}
</div>
{ /* Status-Badge */}
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',

View File

@ -23,7 +23,6 @@ import {
PhotoIcon,
SparklesIcon,
UsersIcon,
FilmIcon,
ClockIcon,
EyeIcon as EyeOutlineIcon,
} from '@heroicons/react/24/outline'
@ -32,7 +31,6 @@ import {
StarIcon as StarSolidIcon,
EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid'
import { useMediaQuery } from '../../lib/useMediaQuery'
import FinishedVideoPreview from './FinishedVideoPreview'
import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
@ -255,16 +253,6 @@ function pill(cls: string) {
const previewBlurCls = (blur?: boolean) =>
blur ? 'blur-md scale-[1.03] brightness-90' : ''
function niceFileLabel(file: string) {
const s = stripHotPrefix(file || '').trim()
return s || '—'
}
function endedLabel(job: RecordJob) {
const ended = (job as any).endedAt ?? (job as any).completedAt ?? job.endedAt
return ended ? shortDate(ended as any) : '—'
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const v of values) {
if (typeof v === 'string') {
@ -335,6 +323,7 @@ function chooseSpriteGrid(count: number): [number, number] {
type ChaturbateRoom = {
gender?: string
location?: string
country?: string
current_show?: string
username?: string
room_subject?: string
@ -446,6 +435,9 @@ type StoredModel = {
keep?: boolean
createdAt?: string
updatedAt?: string
cbOnlineJson?: string | null
cbOnlineFetchedAt?: string | null
cbOnlineLastError?: string | null
}
type Props = {
@ -536,10 +528,9 @@ export default function ModelDetails({
onStopJob
}: Props) {
const isDesktop = useMediaQuery('(min-width: 640px)')
//const isDesktop = useMediaQuery('(min-width: 640px)')
const [models, setModels] = React.useState<StoredModel[]>([])
const [, setModelsLoading] = React.useState(false)
const [room, setRoom] = React.useState<ChaturbateRoom | null>(null)
const [roomMeta, setRoomMeta] = React.useState<Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'> | null>(null)
@ -554,11 +545,13 @@ export default function ModelDetails({
const [running, setRunning] = React.useState<RecordJob[]>([])
const [runningLoading, setRunningLoading] = React.useState(false)
const [bioRefreshSeq, setBioRefreshSeq] = React.useState(0)
const runningReqSeqRef = React.useRef(0)
const [, setBioRefreshSeq] = React.useState(0)
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
const [runningHover, setRunningHover] = React.useState(false)
const [, setRunningHover] = React.useState(false)
const [stopPending, setStopPending] = React.useState(false)
@ -569,6 +562,12 @@ export default function ModelDetails({
const key = normalizeModelKey(modelKey)
type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info')
const bioReqRef = React.useRef<AbortController | null>(null)
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
const [durations, setDurations] = React.useState<Record<string, number>>({})
const [hoverTeaserKey, setHoverTeaserKey] = React.useState<string | null>(null)
@ -623,6 +622,54 @@ export default function ModelDetails({
setStopPending(false)
}, [open, key])
const refreshBio = React.useCallback(async () => {
if (!key) return
// vorherigen abbrechen
bioReqRef.current?.abort()
const ac = new AbortController()
bioReqRef.current = ac
setBioLoading(true)
try {
const cookieHeader = buildChaturbateCookieHeader(cookies)
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}&refresh=1`
const r = await fetch(url, {
cache: 'no-store',
signal: ac.signal,
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
})
if (!r.ok) {
const text = await r.text().catch(() => '')
throw new Error(text || `HTTP ${r.status}`)
}
const data = (await r.json().catch(() => null)) as BioResp
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const nextBio = (data?.bio as BioContext) ?? null
setBioMeta(meta)
setBio(nextBio)
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
mdBioMem.set(key, entry)
ssSet(ssKeyBio(key), entry)
} catch (e: any) {
if (e?.name === 'AbortError') return
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
} finally {
setBioLoading(false)
}
}, [key, cookies])
React.useEffect(() => {
if (open) return
bioReqRef.current?.abort()
bioReqRef.current = null
}, [open])
const refetchModels = React.useCallback(async () => {
try {
const r = await fetch('/api/models', { cache: 'no-store' })
@ -657,6 +704,35 @@ export default function ModelDetails({
}
}, [key, donePage])
const refetchDoneRef = React.useRef(refetchDone)
React.useEffect(() => {
refetchDoneRef.current = refetchDone
}, [refetchDone])
React.useEffect(() => {
if (!open) return
const es = new EventSource('/api/stream')
const onJobs = () => {
// optional
}
const onDone = () => {
void refetchDoneRef.current()
}
es.addEventListener('jobs', onJobs)
es.addEventListener('doneChanged', onDone)
return () => {
es.removeEventListener('jobs', onJobs)
es.removeEventListener('doneChanged', onDone)
es.close()
}
}, [open])
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
function jobFromModelKey(key: string): RecordJob {
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
@ -687,141 +763,10 @@ export default function ModelDetails({
setDonePage(1)
}, [open, modelKey])
// Models list (local flags + stored tags)
React.useEffect(() => {
if (!open) return
let alive = true
setModelsLoading(true)
fetch('/api/models', { cache: 'no-store' })
.then((r) => r.json())
.then((data: StoredModel[]) => {
if (!alive) return
setModels(Array.isArray(data) ? data : [])
})
.catch(() => {
if (!alive) return
setModels([])
})
.finally(() => {
if (!alive) return
setModelsLoading(false)
})
return () => {
alive = false
}
}, [open])
// ✅ Online: nur einmalig laden (kein Polling)
React.useEffect(() => {
if (!open || !key) return
// wenn wir frische Daten aus Cache haben -> keinen Request
const mem = mdOnlineMem.get(key)
const ss = ssGet<OnlineCacheEntry>(ssKeyOnline(key))
const hit =
(mem && isFresh(mem.at) ? mem : null) ||
(ss && isFresh(ss.at) ? ss : null)
if (hit) return
let alive = true
const ac = new AbortController()
;(async () => {
try {
const cookieHeader = buildChaturbateCookieHeader(cookies)
const r = await fetch('/api/chaturbate/online', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : {}),
},
cache: 'no-store',
signal: ac.signal,
body: JSON.stringify({ q: [key], show: [], refresh: false }),
})
const data = (await r.json().catch(() => null)) as OnlineResp
if (!alive) return
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const rooms = Array.isArray(data?.rooms) ? data.rooms : []
const nextRoom = rooms[0] ?? null
setRoomMeta(meta)
setRoom(nextRoom)
const entry: OnlineCacheEntry = { at: Date.now(), room: nextRoom, meta }
mdOnlineMem.set(key, entry)
ssSet(ssKeyOnline(key), entry)
} catch (e: any) {
if (e?.name === 'AbortError') return
if (!alive) return
setRoomMeta({ enabled: undefined, fetchedAt: undefined, lastError: 'Fetch fehlgeschlagen' })
}
})()
return () => {
alive = false
ac.abort()
}
}, [open, key, cookies])
// ✅ NEW: BioContext (proxy)
React.useEffect(() => {
if (!open || !key) return
let alive = true
setBioLoading(true)
setBio(null)
setBioMeta(null)
const cookieHeader = buildChaturbateCookieHeader(cookies)
const url = `/api/chaturbate/biocontext?model=${encodeURIComponent(key)}${
bioRefreshSeq > 0 ? '&refresh=1' : ''
}`
fetch(url, {
cache: 'no-store',
headers: cookieHeader ? { 'X-Chaturbate-Cookie': cookieHeader } : undefined,
})
.then(async (r) => {
if (!r.ok) {
const text = await r.text().catch(() => '')
throw new Error(text || `HTTP ${r.status}`)
}
return r.json()
})
.then((data: BioResp) => {
if (!alive) return
setBioMeta({ enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError })
setBio((data?.bio as BioContext) ?? null)
const meta = { enabled: data?.enabled, fetchedAt: data?.fetchedAt, lastError: data?.lastError }
const nextBio = (data?.bio as BioContext) ?? null
setBioMeta(meta)
setBio(nextBio)
const entry: BioCacheEntry = { at: Date.now(), bio: nextBio, meta }
mdBioMem.set(key, entry)
ssSet(ssKeyBio(key), entry)
})
.catch((e) => {
if (!alive) return
setBioMeta({ enabled: undefined, fetchedAt: undefined, lastError: e?.message || 'Fetch fehlgeschlagen' })
})
.finally(() => {
if (!alive) return
setBioLoading(false)
})
return () => {
alive = false
}
}, [open, key, bioRefreshSeq, cookies])
void refetchModels()
}, [open, refetchModels])
// Done downloads (inkl. keep/<model>/) -> serverseitig paginiert laden
React.useEffect(() => {
@ -834,26 +779,31 @@ export default function ModelDetails({
if (!open) return
if (Array.isArray(runningJobs)) return
let alive = true
const ac = new AbortController()
const seq = ++runningReqSeqRef.current
setRunningLoading(true)
fetch('/api/record/jobs', { cache: 'no-store' })
fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal })
.then((r) => r.json())
.then((data: RecordJob[]) => {
if (!alive) return
if (ac.signal.aborted) return
if (runningReqSeqRef.current !== seq) return
setRunning(Array.isArray(data) ? data : [])
})
.catch(() => {
if (!alive) return
if (ac.signal.aborted) return
if (runningReqSeqRef.current !== seq) return
setRunning([])
})
.finally(() => {
if (!alive) return
if (ac.signal.aborted) return
if (runningReqSeqRef.current !== seq) return
setRunningLoading(false)
})
return () => {
alive = false
ac.abort()
}
}, [open, runningJobs])
@ -862,11 +812,27 @@ export default function ModelDetails({
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key])
const doneMatches = done
const storedRoomFromSnap = React.useMemo<ChaturbateRoom | null>(() => {
const raw = (model as any)?.cbOnlineJson
if (!raw || typeof raw !== 'string') return null
try {
return JSON.parse(raw) as ChaturbateRoom
} catch {
return null
}
}, [model])
const doneTotalPages = React.useMemo(() => {
return Math.max(1, Math.ceil(doneTotalCount / DONE_PAGE_SIZE))
}, [doneTotalCount])
const storedRoomMeta = React.useMemo(() => {
const fetchedAt = (model as any)?.cbOnlineFetchedAt
const lastError = (model as any)?.cbOnlineLastError
if (!fetchedAt && !lastError) return null
return { enabled: true, fetchedAt, lastError } as Pick<OnlineResp, 'enabled' | 'fetchedAt' | 'lastError'>
}, [model])
const effectiveRoom = room ?? storedRoomFromSnap
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
const doneMatches = done
const runningMatches = React.useMemo(() => {
if (!key) return []
@ -876,27 +842,12 @@ export default function ModelDetails({
})
}, [runningList, key])
// ✅ Running-Hero: wenn es einen laufenden Job für dieses Model gibt, nimm dessen Preview
const runningHeroJob = runningMatches.length ? runningMatches[0] : null
const titleName = effectiveRoom?.display_name || model?.modelKey || key || 'Model'
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
const heroImgFull = effectiveRoom?.image_url || heroImg
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
const allTags = React.useMemo(() => {
const a = splitTags(model?.tags)
const b = Array.isArray(room?.tags) ? room!.tags : []
const map = new Map<string, string>()
for (const t of [...a, ...b]) {
const k = String(t).trim().toLowerCase()
if (!k) continue
if (!map.has(k)) map.set(k, String(t).trim())
}
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
}, [model?.tags, room?.tags])
const titleName = room?.display_name || model?.modelKey || key || 'Model'
const heroImg = room?.image_url_360x270 || room?.image_url || ''
const heroImgFull = room?.image_url || heroImg
const roomUrl = room?.chat_room_url_revshare || room?.chat_room_url || ''
const showLabel = (room?.current_show || '').trim().toLowerCase()
const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
const showPill = showLabel
? showLabel === 'public'
? 'Public'
@ -922,6 +873,18 @@ export default function ModelDetails({
const photos = Array.isArray(bio?.photo_sets) ? bio!.photo_sets! : []
const interested = Array.isArray(bio?.interested_in) ? bio!.interested_in! : []
const allTags = React.useMemo(() => {
const a = splitTags(model?.tags)
const b = Array.isArray(effectiveRoom?.tags) ? (effectiveRoom!.tags as string[]) : []
const map = new Map<string, string>()
for (const t of [...a, ...b]) {
const k = String(t).trim().toLowerCase()
if (!k) continue
if (!map.has(k)) map.set(k, String(t).trim())
}
return Array.from(map.values()).sort((x, y) => x.localeCompare(y, 'de'))
}, [model?.tags, effectiveRoom?.tags])
const Stat = ({
icon,
label,
@ -947,22 +910,6 @@ export default function ModelDetails({
return id ? `${id}::${out}` : out
}, [])
const addToSet = (setState: React.Dispatch<React.SetStateAction<Set<string>>>, k: string) =>
setState((prev) => {
if (prev.has(k)) return prev
const next = new Set(prev)
next.add(k)
return next
})
const delFromSet = (setState: React.Dispatch<React.SetStateAction<Set<string>>>, k: string) =>
setState((prev) => {
if (!prev.has(k)) return prev
const next = new Set(prev)
next.delete(k)
return next
})
const handleToggleHot = React.useCallback(
async (job: RecordJob) => {
const out = job.output || ''
@ -1141,9 +1088,6 @@ export default function ModelDetails({
[setScrubIndexForKey]
)
type TabKey = 'info' | 'downloads' | 'running'
const [tab, setTab] = React.useState<TabKey>('info')
React.useEffect(() => {
if (!open) return
setTab('info')
@ -1155,20 +1099,6 @@ export default function ModelDetails({
{ id: 'running', label: 'Running', count: runningMatches.length ? fmtInt(runningMatches.length) : undefined, disabled: runningLoading },
]
// ✅ Adapter: RecordJobActions erwartet void|boolean.
// Dein onToggleHot darf ein Objekt zurückgeben -> wir droppen das.
const onToggleHotAction = React.useCallback(
async (job: RecordJob): Promise<boolean> => {
try {
await onToggleHot?.(job)
return true
} catch {
return false
}
},
[onToggleHot]
)
return (
<Modal
open={open}
@ -1181,6 +1111,22 @@ export default function ModelDetails({
mobileCollapsedImageSrc={heroImg || undefined}
mobileCollapsedImageAlt={titleName}
rightBodyClassName="pt-0 sm:pt-2"
titleRight={
tab === 'info' ? (
<Button
variant="secondary"
className={cn('h-8 px-2 sm:h-9 sm:px-3', 'whitespace-nowrap')}
disabled={bioLoading || !modelKey}
onClick={() => void refreshBio()}
title="BioContext neu abrufen"
>
<span className="inline-flex items-center gap-2">
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
<span className="hidden sm:inline">Bio aktualisieren</span>
</span>
</Button>
) : null
}
left={
<div className="space-y-3 sm:space-y-4">
@ -1237,7 +1183,7 @@ export default function ModelDetails({
</span>
) : null}
{room?.is_hd ? (
{effectiveRoom?.is_hd ? (
<span
className={pill(
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 backdrop-blur dark:text-indigo-200 dark:ring-indigo-400/20'
@ -1247,7 +1193,7 @@ export default function ModelDetails({
</span>
) : null}
{room?.is_new ? (
{effectiveRoom?.is_new ? (
<span
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 backdrop-blur dark:text-amber-200 dark:ring-amber-400/20'
@ -1261,10 +1207,10 @@ export default function ModelDetails({
{/* Title */}
<div className="absolute bottom-3 left-3 right-3">
<div className="truncate text-sm font-semibold text-white drop-shadow">
{room?.display_name || room?.username || model?.modelKey || key || '—'}
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
</div>
<div className="truncate text-xs text-white/85 drop-shadow">
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
</div>
</div>
@ -1380,11 +1326,11 @@ export default function ModelDetails({
{/* Summary */}
<div className="p-3 sm:p-4">
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(room?.num_users)} />
<Stat icon={<UsersIcon className="size-4" />} label="Viewer" value={fmtInt(effectiveRoom?.num_users)} />
<Stat
icon={<SparklesIcon className="size-4" />}
label="Follower"
value={fmtInt(room?.num_followers ?? bioFollowers)}
value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
/>
</div>
@ -1395,7 +1341,7 @@ export default function ModelDetails({
Location
</dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{room?.location || bioLocation || '—'}
{effectiveRoom?.location || bioLocation || '—'}
</dd>
</div>
@ -1405,7 +1351,7 @@ export default function ModelDetails({
Sprache
</dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{room?.spoken_languages || '—'}
{effectiveRoom?.spoken_languages || '—'}
</dd>
</div>
@ -1415,7 +1361,7 @@ export default function ModelDetails({
Online
</dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{fmtHms(room?.seconds_online)}
{fmtHms(effectiveRoom?.seconds_online)}
</dd>
</div>
@ -1425,7 +1371,7 @@ export default function ModelDetails({
Alter
</dt>
<dd className="mt-1 text-[13px] font-semibold leading-snug text-gray-900 dark:text-white break-words">
{bioAge != null ? String(bioAge) : room?.age != null ? String(room.age) : '—'}
{bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
</dd>
</div>
@ -1441,20 +1387,20 @@ export default function ModelDetails({
</dl>
{/* Meta warnings */}
{roomMeta?.enabled === false ? (
{effectiveRoomMeta?.enabled === false ? (
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
Chaturbate-Online ist aktuell deaktiviert.
</div>
) : roomMeta?.lastError ? (
) : effectiveRoomMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
<div className="font-medium">Online-Info: {errorSummary(roomMeta.lastError)}</div>
<div className="font-medium">Online-Info: {errorSummary(effectiveRoomMeta.lastError)}</div>
<details className="mt-1">
<summary className="cursor-pointer select-none text-[11px] text-rose-900/80 underline underline-offset-2 dark:text-rose-200/80">
Details
</summary>
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded-md bg-black/5 p-2 text-[11px] leading-snug dark:bg-white/10">
{errorDetails(roomMeta.lastError)}
{errorDetails(effectiveRoomMeta.lastError)}
</pre>
</details>
</div>
@ -1499,50 +1445,44 @@ export default function ModelDetails({
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
{/* ===================== */}
<div className="sm:hidden px-2 pb-2 space-y-1.5">
{/* Header Card (dein bisheriger Mobile-Block) */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 p-2.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="flex items-start gap-3">
{/* Avatar */}
{/* HERO Header (mobile) */}
<div className="rounded-lg border border-gray-200/70 bg-white/70 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 overflow-hidden">
{/* Hero Background */}
<div className="relative h-40">
{heroImg ? (
<button
type="button"
className="relative shrink-0 overflow-hidden rounded-lg ring-1 ring-black/5 dark:ring-white/10"
className="absolute inset-0 block w-full"
onClick={() => (heroImgFull ? openImage(heroImgFull, titleName) : undefined)}
aria-label="Bild vergrößern"
>
{heroImg ? (
<img
src={heroImg}
src={heroImgFull || heroImg}
alt={titleName}
className={cn('size-10 object-cover', previewBlurCls(blurPreviews))}
/>
) : (
<div className="size-10 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
)}
{/* Status dot */}
<span
aria-hidden
className={cn(
'absolute bottom-1.5 right-1.5 size-2.5 rounded-full ring-2 ring-white/80 dark:ring-gray-900/60',
(effectivePresenceLabel || '').toLowerCase() === 'online' ? 'bg-emerald-400' : 'bg-gray-400'
)}
className={cn('h-full w-full object-cover', previewBlurCls(blurPreviews))}
/>
</button>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/10 via-transparent to-sky-500/10" />
)}
{/* Name + actions (right) + pills */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
{/* Name */}
{/* Gradient overlay */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/0"
/>
{/* Top row: name + action icons */}
<div className="absolute left-3 right-3 top-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{room?.display_name || room?.username || model?.modelKey || key || '—'}
<div className="truncate text-base font-semibold text-white drop-shadow">
{effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
</div>
<div className="truncate text-xs text-gray-600 dark:text-gray-300">
{room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
<div className="truncate text-xs text-white/85 drop-shadow">
{effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
</div>
</div>
{/* ✅ Buttons rechts neben Name */}
<div className="shrink-0 flex items-center gap-1.5">
{/* Watched */}
<button
@ -1555,29 +1495,15 @@ export default function ModelDetails({
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.watching
? 'bg-sky-500/15 ring-sky-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
model?.watching ? 'bg-sky-500/25 ring-sky-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
>
<span className="relative inline-block size-4">
<EyeOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<EyeSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-sky-500'
)}
/>
<EyeOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<EyeSolidIcon className={cn('absolute inset-0 size-4', model?.watching ? 'opacity-100' : 'opacity-0', 'text-sky-200')} />
</span>
</button>
@ -1592,29 +1518,15 @@ export default function ModelDetails({
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.favorite
? 'bg-amber-500/15 ring-amber-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
model?.favorite ? 'bg-amber-500/25 ring-amber-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={Boolean(model?.favorite)}
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
>
<span className="relative inline-block size-4">
<StarOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<StarSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.favorite ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-amber-500'
)}
/>
<StarOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<StarSolidIcon className={cn('absolute inset-0 size-4', model?.favorite ? 'opacity-100' : 'opacity-0', 'text-amber-200')} />
</span>
</button>
@ -1629,91 +1541,52 @@ export default function ModelDetails({
className={cn(
'inline-flex items-center justify-center rounded-full p-1.5 ring-1 ring-inset backdrop-blur',
'transition active:scale-[0.98]',
model?.liked
? 'bg-rose-500/15 ring-rose-200/30'
: 'bg-black/5 ring-black/10 dark:bg-white/5 dark:ring-white/10'
model?.liked ? 'bg-rose-500/25 ring-rose-200/30' : 'bg-black/30 ring-white/20'
)}
title={model?.liked ? 'Like entfernen' : 'Liken'}
aria-pressed={model?.liked === true}
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
>
<span className="relative inline-block size-4">
<HeartOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-gray-700 dark:text-white/70'
)}
/>
<HeartSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.liked ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-rose-500'
)}
/>
<HeartOutlineIcon className={cn('absolute inset-0 size-4', 'text-white/70')} />
<HeartSolidIcon className={cn('absolute inset-0 size-4', model?.liked ? 'opacity-100' : 'opacity-0', 'text-rose-200')} />
</span>
</button>
</div>
</div>
{/* Pills (jetzt unter der Namenszeile) */}
<div className="mt-1 flex flex-wrap items-center gap-1.5">
{/* Pills bottom-left */}
<div className="absolute left-3 bottom-3 flex flex-wrap items-center gap-1.5">
{showPill ? (
<span
className={pill(
'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
)}
>
<span className={pill('bg-white/15 text-white ring-white/20')}>
{showPill}
</span>
) : null}
{bioStatus ? (
{effectivePresenceLabel ? (
<span
className={pill(
bioStatus.toLowerCase() === 'online'
? 'bg-emerald-500/10 text-emerald-900 ring-emerald-200 dark:text-emerald-200 dark:ring-emerald-400/20'
: 'bg-gray-500/10 text-gray-900 ring-gray-200 dark:text-gray-200 dark:ring-white/15'
(effectivePresenceLabel || '').toLowerCase() === 'online'
? 'bg-emerald-500/25 text-white ring-emerald-200/30'
: 'bg-white/15 text-white ring-white/20'
)}
>
{bioStatus}
{effectivePresenceLabel}
</span>
) : null}
{room?.is_hd ? (
<span
className={pill(
'bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20'
)}
>
HD
</span>
) : null}
{room?.is_new ? (
<span
className={pill(
'bg-amber-500/10 text-amber-900 ring-amber-200 dark:text-amber-200 dark:ring-amber-400/20'
)}
>
NEW
</span>
) : null}
{effectiveRoom?.is_hd ? <span className={pill('bg-white/15 text-white ring-white/20')}>HD</span> : null}
{effectiveRoom?.is_new ? <span className={pill('bg-white/15 text-white ring-white/20')}>NEW</span> : null}
</div>
{/* Row unten: nur Room-Link (optional) */}
{/* Room link bottom-right */}
{roomUrl ? (
<div className="mt-1.5 flex justify-end">
<div className="absolute right-3 bottom-3">
<a
href={roomUrl}
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium',
'border border-gray-200/70 bg-white/70 text-gray-900 shadow-sm backdrop-blur hover:bg-white',
'dark:border-white/10 dark:bg-white/5 dark:text-white'
)}
className="inline-flex h-8 items-center justify-center gap-1 rounded-lg px-3 text-sm font-medium bg-white/15 text-white ring-1 ring-white/20 backdrop-blur hover:bg-white/20"
title="Room öffnen"
>
<ArrowTopRightOnSquareIcon className="size-4" />
@ -1722,21 +1595,21 @@ export default function ModelDetails({
</div>
) : null}
</div>
</div>
{/* Quick stats (compact row) */}
<div className="mt-1 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[12px] text-gray-700 dark:text-gray-200">
{/* Quick stats row (unter dem Hero) */}
<div className="px-3 py-2 text-[12px] text-gray-700 dark:text-gray-200">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1">
<UsersIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_users)}</span>
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_users)}</span>
</span>
<span className="inline-flex items-center gap-1">
<SparklesIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(room?.num_followers ?? bioFollowers)}</span>
<span className="font-semibold text-gray-900 dark:text-white">{fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}</span>
</span>
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5 text-gray-400" />
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(room?.seconds_online)}</span>
<span className="font-semibold text-gray-900 dark:text-white">{fmtHms(effectiveRoom?.seconds_online)}</span>
</span>
<span className="inline-flex items-center gap-1">
<CalendarDaysIcon className="size-3.5 text-gray-400" />
@ -1747,13 +1620,13 @@ export default function ModelDetails({
</div>
{/* Meta warnings (mobile) */}
{roomMeta?.enabled === false ? (
{effectiveRoomMeta?.enabled === false ? (
<div className="mt-2 rounded-lg border border-amber-200/60 bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200">
Chaturbate-Online ist aktuell deaktiviert.
</div>
) : roomMeta?.lastError ? (
) : effectiveRoomMeta?.lastError ? (
<div className="mt-2 rounded-lg border border-rose-200/60 bg-rose-50/70 px-3 py-2 text-xs text-rose-900 dark:border-rose-400/20 dark:bg-rose-400/10 dark:text-rose-200">
Online-Info: {roomMeta.lastError}
Online-Info: {effectiveRoomMeta.lastError}
</div>
) : null}
@ -1777,6 +1650,7 @@ export default function ModelDetails({
</div>
) : null}
</div>
</div>
{/* Tags (mobile, compact row) */}
{allTags.length ? (
@ -1806,15 +1680,15 @@ export default function ModelDetails({
{/* ===================== */}
{/* (dein bisheriger Header) Row 1: Meta + Actions */}
{/* ===================== */}
<div className="flex items-start justify-between gap-2 px-2 py-2 sm:px-4">
<div className="hidden sm:flex items-start justify-between gap-2 px-2 py-2 sm:px-4">
{/* Meta */}
<div className="min-w-0">
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
{key ? (
<div className="flex flex-wrap gap-x-2 gap-y-1">
{roomMeta?.fetchedAt ? (
{effectiveRoomMeta?.fetchedAt ? (
<span className="text-gray-500 dark:text-gray-400">
Online-Stand: {fmtDateTime(roomMeta.fetchedAt)}
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
</span>
) : null}
{bioMeta?.fetchedAt ? (
@ -1844,21 +1718,6 @@ export default function ModelDetails({
{/* Actions */}
<div className="flex shrink-0 items-center gap-2">
{tab === 'info' ? (
<Button
variant="secondary"
className={cn('h-9 px-3 text-sm', 'whitespace-nowrap')}
disabled={bioLoading || !modelKey}
onClick={() => setBioRefreshSeq((x) => x + 1)}
title="BioContext neu abrufen"
>
<span className="inline-flex items-center gap-2">
<ArrowPathIcon className={cn('size-4', bioLoading ? 'animate-spin' : '')} />
<span className="hidden sm:inline">Bio aktualisieren</span>
</span>
</Button>
) : null}
{roomUrl ? (
<a
href={roomUrl}
@ -1914,8 +1773,8 @@ export default function ModelDetails({
<div className="text-sm font-semibold text-gray-900 dark:text-white">Room Subject</div>
</div>
<div className="mt-2 text-sm text-gray-800 dark:text-gray-100">
{room?.room_subject ? (
<p className="line-clamp-4 whitespace-pre-wrap break-words">{room.room_subject}</p>
{effectiveRoom?.room_subject ? (
<p className="line-clamp-4 whitespace-pre-wrap break-words">{effectiveRoom.room_subject}</p>
) : (
<p className="text-gray-600 dark:text-gray-300">Keine Subject-Info vorhanden.</p>
)}
@ -2547,11 +2406,6 @@ export default function ModelDetails({
const dur = runtimeOf(j)
const size = formatBytes(sizeBytesOf(j))
// Flags: aktuelles Model
const isFav = Boolean(model?.favorite)
const isLiked = model?.liked === true
const isWatching = Boolean(model?.watching)
const cardTags = allTags
const modelImageSrc = firstNonEmptyString(heroImgFull, heroImg)
@ -2630,8 +2484,6 @@ export default function ModelDetails({
const hasSpriteScrubber = hasScrubberUi && spriteCols > 0 && spriteRows > 0
const scrubberCount = hasScrubberUi ? spriteCount : 0
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
const hasScrubber = hasScrubberUi
const activeScrubIndex = scrubIndexByKey[k]
const scrubProgressRatio =

View File

@ -442,8 +442,19 @@ export default function ModelsTab() {
setLoading(true)
setErr(null)
try {
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' })
setModels(Array.isArray(list) ? list : [])
const res = await fetch('/api/models', { cache: 'no-store' as any })
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`))
const data = await res.json().catch(() => null)
// ✅ akzeptiere beide Formen: Array ODER { items: [...] }
const list: StoredModel[] = Array.isArray(data?.items)
? (data.items as StoredModel[])
: Array.isArray(data)
? (data as StoredModel[])
: []
setModels(list)
void refreshVideoCounts()
} catch (e: any) {
setErr(e?.message ?? String(e))

View File

@ -1744,8 +1744,7 @@ export default function Player({
const videoChrome = (
<div
className={cn(
'relative overflow-visible',
expanded ? 'flex-1 min-h-0' : miniDesktop ? 'flex-1 min-h-0' : 'aspect-video'
'relative overflow-visible flex-1 min-h-0'
)}
onMouseEnter={() => {
if (!miniDesktop || !canHover) return