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 */} - + ) : ( +
+ )} - {/* Status dot */} - - + {/* Gradient overlay */} +
- {/* Name + actions (right) + pills */} -
-
- {/* Name */} -
-
- {room?.display_name || room?.username || model?.modelKey || key || '—'} -
-
- {room?.username ? `@${room.username}` : model?.modelKey ? `@${model.modelKey}` : ''} -
+ {/* Top row: name + action icons */} +
+
+
+ {effectiveRoom?.display_name || effectiveRoom?.username || model?.modelKey || key || '—'}
- - {/* ✅ Buttons rechts neben Name */} -
- {/* Watched */} - - - {/* Favorite */} - - - {/* Like */} - +
+ {effectiveRoom?.username ? `@${effectiveRoom.username}` : model?.modelKey ? `@${model.modelKey}` : ''}
- {/* Pills (jetzt unter der Namenszeile) */} -
- {showPill ? ( - - {showPill} +
+ {/* Watched */} + - {bioStatus ? ( - - {bioStatus} + {/* Favorite */} + - {room?.is_hd ? ( - - HD + {/* Like */} +
+
- {/* Row unten: nur Room-Link (optional) */} - {roomUrl ? ( - + {/* Pills bottom-left */} +
+ {showPill ? ( + + {showPill} + ) : null} -
-
- {/* Quick stats (compact row) */} -
- - - {fmtInt(room?.num_users)} - - - - {fmtInt(room?.num_followers ?? bioFollowers)} - - - - {fmtHms(room?.seconds_online)} - - - - - {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'} - - -
+ {effectivePresenceLabel ? ( + + {effectivePresenceLabel} + + ) : null} - {/* Meta warnings (mobile) */} - {roomMeta?.enabled === false ? ( -
- Chaturbate-Online ist aktuell deaktiviert. + {effectiveRoom?.is_hd ? HD : null} + {effectiveRoom?.is_new ? NEW : null}
- ) : roomMeta?.lastError ? ( -
- Online-Info: {roomMeta.lastError} -
- ) : null} - {bioMeta?.enabled === false ? ( -
- BioContext ist aktuell deaktiviert. -
- ) : bioMeta?.lastError ? ( -
-
-
BioContext: {errorSummary(bioMeta.lastError)}
+ {/* Room link bottom-right */} + {roomUrl ? ( + -
- - Details - -
-                      {errorDetails(bioMeta.lastError)}
-                    
-
+ ) : null} +
+ + {/* Quick stats row (unter dem Hero) */} +
+
+ + + {fmtInt(effectiveRoom?.num_users)} + + + + {fmtInt(effectiveRoom?.num_followers ?? bioFollowers)} + + + + {fmtHms(effectiveRoom?.seconds_online)} + + + + + {bio?.last_broadcast ? shortDate(bio.last_broadcast) : '—'} + +
- ) : null} + + {/* Meta warnings (mobile) */} + {effectiveRoomMeta?.enabled === false ? ( +
+ Chaturbate-Online ist aktuell deaktiviert. +
+ ) : effectiveRoomMeta?.lastError ? ( +
+ Online-Info: {effectiveRoomMeta.lastError} +
+ ) : null} + + {bioMeta?.enabled === false ? ( +
+ BioContext ist aktuell deaktiviert. +
+ ) : bioMeta?.lastError ? ( +
+
+
BioContext: {errorSummary(bioMeta.lastError)}
+
+
+ + Details + +
+                        {errorDetails(bioMeta.lastError)}
+                      
+
+
+ ) : null} +
{/* Tags (mobile, compact row) */} @@ -1806,15 +1680,15 @@ export default function ModelDetails({ {/* ===================== */} {/* (dein bisheriger Header) Row 1: Meta + Actions */} {/* ===================== */} -
+
{/* Meta */}
{key ? (
- {roomMeta?.fetchedAt ? ( + {effectiveRoomMeta?.fetchedAt ? ( - Online-Stand: {fmtDateTime(roomMeta.fetchedAt)} + Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)} ) : null} {bioMeta?.fetchedAt ? ( @@ -1844,21 +1718,6 @@ export default function ModelDetails({ {/* Actions */}
- {tab === 'info' ? ( - - ) : null} - {roomUrl ? ( Room Subject
- {room?.room_subject ? ( -

{room.room_subject}

+ {effectiveRoom?.room_subject ? ( +

{effectiveRoom.room_subject}

) : (

Keine Subject-Info vorhanden.

)} @@ -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 = diff --git a/frontend/src/components/ui/ModelsTab.tsx b/frontend/src/components/ui/ModelsTab.tsx index 14f08be..07e0208 100644 --- a/frontend/src/components/ui/ModelsTab.tsx +++ b/frontend/src/components/ui/ModelsTab.tsx @@ -442,8 +442,19 @@ export default function ModelsTab() { setLoading(true) setErr(null) try { - const list = await apiJSON('/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)) diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx index 886af97..9e99fc5 100644 --- a/frontend/src/components/ui/Player.tsx +++ b/frontend/src/components/ui/Player.tsx @@ -1744,8 +1744,7 @@ export default function Player({ const videoChrome = (
{ if (!miniDesktop || !canHover) return