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 Liked *bool `json:"liked"` // nil = keine Angabe
} }
type modelStore struct { type jsonModelStore struct {
mu sync.Mutex mu sync.Mutex
path string path string
loaded bool loaded bool
items []Model items []Model
} }
var models = &modelStore{ var models = &jsonModelStore{
path: filepath.Join("data", "models.json"), 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"}) modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return 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 // ✅ Profilbild-Blob aus DB ausliefern

View File

@ -3,7 +3,9 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -25,6 +27,11 @@ type StoredModel struct {
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano 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"` ProfileImageURL string `json:"profileImageUrl,omitempty"`
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=... ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano 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 ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
last_stream = CASE 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 ELSE last_stream
END, END,
watching = CASE WHEN $7=true THEN true ELSE watching END, watching = CASE WHEN $7=true THEN true ELSE watching END,
favorite = CASE WHEN $8=true THEN true ELSE favorite END, favorite = CASE WHEN $8=true THEN true ELSE favorite END,
hot = CASE WHEN $9=true THEN true ELSE hot END, hot = CASE WHEN $9=true THEN true ELSE hot END,
keep = CASE WHEN $10=true THEN true ELSE keep 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 updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
WHERE id = $13; WHERE id = $13;
@ -817,12 +827,21 @@ func (s *ModelStore) List() []StoredModel {
return []StoredModel{} return []StoredModel{}
} }
// ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'') q1 := `
rows, err := s.db.Query(`
SELECT SELECT
id,input,is_url,host,path,model_key, id,
tags, last_stream, COALESCE(input,'') as input,
last_seen_online, last_seen_online_at, 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,''), COALESCE(profile_image_url,''),
profile_image_updated_at, 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, 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 created_at, updated_at
FROM models FROM models
ORDER BY updated_at DESC; 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 { 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() defer rows.Close()
@ -841,14 +892,17 @@ ORDER BY updated_at DESC;
for rows.Next() { for rows.Next() {
var ( var (
id, input, host, path, modelKey, tags string id, input, host, path, modelKey, tags string
isURL bool
isURL bool
lastStream sql.NullTime lastStream sql.NullTime
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
@ -863,6 +917,7 @@ ORDER BY updated_at DESC;
&id, &input, &isURL, &host, &path, &modelKey, &id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, &tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt, &lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&watching, &favorite, &hot, &keep, &liked, &watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt, &createdAt, &updatedAt,
@ -882,6 +937,10 @@ ORDER BY updated_at DESC;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, Hot: hot,
@ -919,6 +978,166 @@ func (s *ModelStore) Meta() ModelsMeta {
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)} 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) // hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite { func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil { if err := s.ensureInit(); err != nil {
@ -933,14 +1152,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
) )
if hostFilter == "" { if hostFilter == "" {
rows, err = s.db.Query(` rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models FROM models
WHERE watching = true WHERE watching = true
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
`) `)
} else { } else {
rows, err = s.db.Query(` rows, err = s.db.Query(`
SELECT id,input,host,model_key,watching SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models FROM models
WHERE watching = true AND host = $1 WHERE watching = true AND host = $1
ORDER BY updated_at DESC; ORDER BY updated_at DESC;
@ -1200,6 +1419,10 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
lastSeenOnline sql.NullBool lastSeenOnline sql.NullBool
lastSeenOnlineAt sql.NullTime lastSeenOnlineAt sql.NullTime
cbOnlineJSON string
cbOnlineFetchedAt sql.NullTime
cbOnlineLastError string
profileImageURL string profileImageURL string
profileImageUpdatedAt sql.NullTime profileImageUpdatedAt sql.NullTime
hasProfileImage int64 hasProfileImage int64
@ -1210,11 +1433,21 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) {
createdAt, updatedAt time.Time createdAt, updatedAt time.Time
) )
err := s.db.QueryRow(` // q1: mit optionaler Spalte cb_online_last_error
q1 := `
SELECT SELECT
input,is_url,host,path,model_key, COALESCE(input,'') as input,
tags, last_stream, is_url,
last_seen_online, last_seen_online_at, 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,''), COALESCE(profile_image_url,''),
profile_image_updated_at, 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, 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 created_at, updated_at
FROM models FROM models
WHERE id=$1; WHERE id=$1;
`, id).Scan( `
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream, // q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
&lastSeenOnline, &lastSeenOnlineAt, // Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage, q2 := `
&watching, &favorite, &hot, &keep, &liked, SELECT
&createdAt, &updatedAt, 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 { if err != nil {
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden") 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{ m := StoredModel{
@ -1249,6 +1525,10 @@ WHERE id=$1;
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt),
CbOnlineLastError: cbOnlineLastError,
Watching: watching, Watching: watching,
Favorite: favorite, Favorite: favorite,
Hot: hot, 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}> <div className="truncate text-base font-semibold text-gray-900 dark:text-white" title={name}>
{name} {name}
</div> </div>
{ /* Status-Badge */}
<span <span
className={[ className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold', 'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold',

File diff suppressed because it is too large Load Diff

View File

@ -442,8 +442,19 @@ export default function ModelsTab() {
setLoading(true) setLoading(true)
setErr(null) setErr(null)
try { try {
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' }) const res = await fetch('/api/models', { cache: 'no-store' as any })
setModels(Array.isArray(list) ? list : []) 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() void refreshVideoCounts()
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))

View File

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