diff --git a/backend/models.go b/backend/models.go
index 54dc003..de73a62 100644
--- a/backend/models.go
+++ b/backend/models.go
@@ -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"),
}
diff --git a/backend/models_api.go b/backend/models_api.go
index 197a79d..9e7b3dd 100644
--- a/backend/models_api.go
+++ b/backend/models_api.go
@@ -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
diff --git a/backend/models_store.go b/backend/models_store.go
index c25bf4d..fc24003 100644
--- a/backend/models_store.go
+++ b/backend/models_store.go
@@ -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,15 +783,18 @@ 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
- ELSE last_stream
+ WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
+ ELSE last_stream
END,
watching = CASE WHEN $7=true THEN true ELSE watching END,
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,9 +849,41 @@ 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 {
- return []StoredModel{}
+ // ✅ 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()
@@ -841,14 +892,17 @@ ORDER BY updated_at DESC;
for rows.Next() {
var (
id, input, host, path, modelKey, tags string
-
- isURL bool
+ isURL bool
lastStream sql.NullTime
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(
- &input, &isURL, &host, &path, &modelKey,
- &tags, &lastStream,
- &lastSeenOnline, &lastSeenOnlineAt,
- &profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
- &watching, &favorite, &hot, &keep, &liked,
- &createdAt, &updatedAt,
- )
+`
+
+ // 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,
diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx
index 8d9b74b..7745179 100644
--- a/frontend/src/components/ui/Downloads.tsx
+++ b/frontend/src/components/ui/Downloads.tsx
@@ -501,7 +501,7 @@ function DownloadsCardRow({
{name}
-
+ { /* Status-Badge */}
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([])
- const [, setModelsLoading] = React.useState(false)
const [room, setRoom] = React.useState(null)
const [roomMeta, setRoomMeta] = React.useState | null>(null)
@@ -554,11 +545,13 @@ export default function ModelDetails({
const [running, setRunning] = React.useState([])
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('info')
+
+ const bioReqRef = React.useRef(null)
+
+
// ===== Gallery UI State (wie FinishedDownloadsGalleryView) =====
const [durations, setDurations] = React.useState>({})
const [hoverTeaserKey, setHoverTeaserKey] = React.useState(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: _MM_DD_YYYY__HH-MM-SS.ext
@@ -680,148 +756,17 @@ export default function ModelDetails({
const runningList = React.useMemo(() => {
return Array.isArray(runningJobs) ? runningJobs : running
- }, [runningJobs, running])
+ }, [runningJobs, running])
React.useEffect(() => {
if (!open) return
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(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//) -> serverseitig paginiert laden
React.useEffect(() => {
@@ -834,39 +779,60 @@ 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' })
- .then((r) => r.json())
- .then((data: RecordJob[]) => {
- if (!alive) return
+ fetch('/api/record/jobs', { cache: 'no-store', signal: ac.signal })
+ .then((r) => r.json())
+ .then((data: RecordJob[]) => {
+ if (ac.signal.aborted) return
+ if (runningReqSeqRef.current !== seq) return
setRunning(Array.isArray(data) ? data : [])
- })
- .catch(() => {
- if (!alive) return
+ })
+ .catch(() => {
+ if (ac.signal.aborted) return
+ if (runningReqSeqRef.current !== seq) return
setRunning([])
- })
- .finally(() => {
- if (!alive) return
+ })
+ .finally(() => {
+ if (ac.signal.aborted) return
+ if (runningReqSeqRef.current !== seq) return
setRunningLoading(false)
- })
+ })
return () => {
- alive = false
+ ac.abort()
}
- }, [open, runningJobs])
+ }, [open, runningJobs])
const model = React.useMemo(() => {
if (!key) return null
return models.find((m) => (m.modelKey || '').toLowerCase() === key) ?? null
}, [models, key])
- const doneMatches = done
+ const storedRoomFromSnap = React.useMemo(() => {
+ 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
+ }, [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()
- 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()
+ 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>>, 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>>, 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('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 => {
- try {
- await onToggleHot?.(job)
- return true
- } catch {
- return false
- }
- },
- [onToggleHot]
- )
-
return (
void refreshBio()}
+ title="BioContext neu abrufen"
+ >
+
+
+ Bio aktualisieren
+
+
+ ) : null
+ }
left={
@@ -1237,7 +1183,7 @@ export default function ModelDetails({
) : null}
- {room?.is_hd ? (
+ {effectiveRoom?.is_hd ? (
) : null}
- {room?.is_new ? (
+ {effectiveRoom?.is_new ? (
- {room?.display_name || room?.username || model?.modelKey || key || '—'}
+ {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
- {room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
+ {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
@@ -1380,11 +1326,11 @@ export default function ModelDetails({
{/* Summary */}
- } label="Viewer" value={fmtInt(room?.num_users)} />
+ } label="Viewer" value={fmtInt(effectiveRoom?.num_users)} />
}
label="Follower"
- value={fmtInt(room?.num_followers ?? bioFollowers)}
+ value={fmtInt(effectiveRoom?.num_followers ?? bioFollowers)}
/>
@@ -1395,7 +1341,7 @@ export default function ModelDetails({
Location
- {room?.location || bioLocation || '—'}
+ {effectiveRoom?.location || bioLocation || '—'}
@@ -1405,7 +1351,7 @@ export default function ModelDetails({
Sprache
- {room?.spoken_languages || '—'}
+ {effectiveRoom?.spoken_languages || '—'}
@@ -1415,7 +1361,7 @@ export default function ModelDetails({
Online
- {fmtHms(room?.seconds_online)}
+ {fmtHms(effectiveRoom?.seconds_online)}
@@ -1425,7 +1371,7 @@ export default function ModelDetails({
Alter
- {bioAge != null ? String(bioAge) : room?.age != null ? String(room.age) : '—'}
+ {bioAge != null ? String(bioAge) : effectiveRoom?.age != null ? String(effectiveRoom.age) : '—'}
@@ -1441,20 +1387,20 @@ export default function ModelDetails({
{/* Meta warnings */}
- {roomMeta?.enabled === false ? (
+ {effectiveRoomMeta?.enabled === false ? (
Chaturbate-Online ist aktuell deaktiviert.
- ) : roomMeta?.lastError ? (
+ ) : effectiveRoomMeta?.lastError ? (
-
Online-Info: {errorSummary(roomMeta.lastError)}
+
Online-Info: {errorSummary(effectiveRoomMeta.lastError)}
Details
- {errorDetails(roomMeta.lastError)}
+ {errorDetails(effectiveRoomMeta.lastError)}
@@ -1499,283 +1445,211 @@ export default function ModelDetails({
{/* ✅ MOBILE: Header + Tags immer sichtbar (über Tabs) */}
{/* ===================== */}
- {/* Header Card (dein bisheriger Mobile-Block) */}
-
-
- {/* Avatar */}
-