updated
This commit is contained in:
parent
a2891a2cf5
commit
160544a65d
@ -1,5 +1,4 @@
|
||||
// backend\chaturbate_online.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
@ -22,7 +21,6 @@ import (
|
||||
const chaturbateOnlineRoomsURL = "https://chaturbate.com/affiliates/api/onlinerooms/?format=json&wm=827SM"
|
||||
|
||||
// ChaturbateRoom bildet die Felder ab, die die Online-Rooms API liefert.
|
||||
// (Du kannst das später problemlos erweitern, wenn du weitere Felder brauchst.)
|
||||
type ChaturbateRoom struct {
|
||||
Gender string `json:"gender"`
|
||||
Location string `json:"location"`
|
||||
@ -75,7 +73,7 @@ type chaturbateCache struct {
|
||||
LiteByUser map[string]ChaturbateOnlineRoomLite
|
||||
|
||||
FetchedAt time.Time
|
||||
LastAttempt time.Time // ✅ wichtig für Bootstrap-Cooldown (siehe Punkt 2)
|
||||
LastAttempt time.Time
|
||||
LastErr string
|
||||
}
|
||||
|
||||
@ -84,7 +82,7 @@ var (
|
||||
cbMu sync.RWMutex
|
||||
cb chaturbateCache
|
||||
|
||||
// ✅ Optional: ModelStore, um Tags aus der Online-API zu übernehmen
|
||||
// ✅ Optional: ModelStore, um Tags/Bilder/Status aus der Online-API zu übernehmen
|
||||
cbModelStore *ModelStore
|
||||
)
|
||||
|
||||
@ -232,11 +230,16 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
img := strings.TrimSpace(rm.ImageURL360)
|
||||
if img == "" {
|
||||
img = strings.TrimSpace(rm.ImageURL)
|
||||
}
|
||||
|
||||
m[u] = ChaturbateOnlineRoomLite{
|
||||
Username: rm.Username,
|
||||
CurrentShow: rm.CurrentShow,
|
||||
ChatRoomURL: rm.ChatRoomURL,
|
||||
ImageURL: rm.ImageURL,
|
||||
ImageURL: img,
|
||||
|
||||
Gender: rm.Gender,
|
||||
Country: rm.Country,
|
||||
@ -248,26 +251,145 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite
|
||||
return m
|
||||
}
|
||||
|
||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||
// --- Profilbild Download + Persist (online -> offline) ---
|
||||
|
||||
func selectBestRoomImageURL(rm ChaturbateRoom) string {
|
||||
if v := strings.TrimSpace(rm.ImageURL360); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(rm.ImageURL); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchProfileImageBytes(ctx context.Context, rawURL string) (mime string, data []byte, err error) {
|
||||
u := strings.TrimSpace(rawURL)
|
||||
if u == "" {
|
||||
return "", nil, fmt.Errorf("empty image url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
req.Header.Set("Accept", "image/*,*/*;q=0.8")
|
||||
|
||||
resp, err := cbHTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", nil, fmt.Errorf("image fetch HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// Sicherheitslimit (Profilbilder sind klein)
|
||||
const maxImageBytes = 4 << 20 // 4 MiB
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "", nil, fmt.Errorf("empty image body")
|
||||
}
|
||||
if len(b) > maxImageBytes {
|
||||
return "", nil, fmt.Errorf("image too large")
|
||||
}
|
||||
|
||||
ct := strings.TrimSpace(strings.ToLower(resp.Header.Get("Content-Type")))
|
||||
if i := strings.Index(ct, ";"); i >= 0 {
|
||||
ct = strings.TrimSpace(ct[:i])
|
||||
}
|
||||
|
||||
return ct, b, nil
|
||||
}
|
||||
|
||||
func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]ChaturbateRoom, fetchedAt time.Time) {
|
||||
if cbModelStore == nil || prevRoomsByUser == nil {
|
||||
return
|
||||
}
|
||||
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
for userLower, prevRm := range prevRoomsByUser {
|
||||
// war vorher online und ist jetzt noch online => kein Offline-Transition
|
||||
if _, stillOnline := newRoomsByUser[userLower]; stillOnline {
|
||||
continue
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(prevRm.Username)
|
||||
if username == "" {
|
||||
username = strings.TrimSpace(userLower)
|
||||
}
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1) Offline Status persistieren
|
||||
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", username, false, seenAt)
|
||||
|
||||
// 2) Letztes bekanntes Profilbild persistieren
|
||||
imgURL := selectBestRoomImageURL(prevRm)
|
||||
if imgURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// URL immer merken (Fallback / Diagnose)
|
||||
_ = cbModelStore.SetProfileImageURLOnly("chaturbate.com", username, imgURL, seenAt)
|
||||
|
||||
// Blob speichern (best effort)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
mime, data, err := fetchProfileImageBytes(ctx, imgURL)
|
||||
cancel()
|
||||
if err != nil || len(data) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = cbModelStore.SetProfileImage("chaturbate.com", username, imgURL, mime, data, seenAt)
|
||||
}
|
||||
}
|
||||
|
||||
// cbApplySnapshot ersetzt atomar den Cache-Snapshot und triggert anschließend
|
||||
// offline-transition persist (best effort, außerhalb des Locks).
|
||||
func cbApplySnapshot(rooms []ChaturbateRoom) time.Time {
|
||||
var prevRoomsByUser map[string]ChaturbateRoom
|
||||
newRoomsByUser := indexRoomsByUser(rooms)
|
||||
newLiteByUser := indexLiteByUser(rooms)
|
||||
fetchedAtNow := time.Now()
|
||||
|
||||
cbMu.Lock()
|
||||
if cb.RoomsByUser != nil {
|
||||
prevRoomsByUser = cb.RoomsByUser
|
||||
}
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cb.RoomsByUser = newRoomsByUser
|
||||
cb.LiteByUser = newLiteByUser
|
||||
cb.FetchedAt = fetchedAtNow
|
||||
cbMu.Unlock()
|
||||
|
||||
// Offline-Transitions bewusst außerhalb des Locks
|
||||
if cbModelStore != nil && prevRoomsByUser != nil {
|
||||
go persistOfflineTransitions(prevRoomsByUser, newRoomsByUser, fetchedAtNow)
|
||||
}
|
||||
|
||||
return fetchedAtNow
|
||||
}
|
||||
|
||||
// startChaturbateOnlinePoller pollt die API alle paar Sekunden,
|
||||
// aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist.
|
||||
func startChaturbateOnlinePoller(store *ModelStore) {
|
||||
// ✅ etwas langsamer pollen (weniger Last)
|
||||
const interval = 10 * time.Second
|
||||
|
||||
// ✅ Tags-Fill ist teuer -> max alle 10 Minuten
|
||||
const tagsFillEvery = 10 * time.Minute
|
||||
|
||||
// nur loggen, wenn sich etwas ändert (sonst spammt es)
|
||||
lastLoggedCount := -1
|
||||
lastLoggedErr := ""
|
||||
|
||||
// Tags-Fill Throttle (lokal in der Funktion)
|
||||
var tagsMu sync.Mutex
|
||||
var tagsLast time.Time
|
||||
|
||||
// sofort ein initialer Tick
|
||||
first := time.NewTimer(0)
|
||||
defer first.Stop()
|
||||
|
||||
@ -284,7 +406,7 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ immer merken: wir haben es versucht (hilft dem Handler beim Bootstrap-Cooldown)
|
||||
// immer merken: wir haben es versucht
|
||||
cbMu.Lock()
|
||||
cb.LastAttempt = time.Now()
|
||||
cbMu.Unlock()
|
||||
@ -293,17 +415,14 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
||||
rooms, err := fetchChaturbateOnlineRooms(ctx)
|
||||
cancel()
|
||||
|
||||
cbMu.Lock()
|
||||
if err != nil {
|
||||
// ❗️bei Fehler NICHT fetchedAt aktualisieren,
|
||||
// sonst wirkt der Cache "frisch", obwohl rooms alt sind.
|
||||
cbMu.Lock()
|
||||
cb.LastErr = err.Error()
|
||||
|
||||
// ❗️damit offline Models nicht hängen bleiben: Cache leeren
|
||||
// Fehler => Cache leeren (damit offline nicht hängen bleibt)
|
||||
cb.Rooms = nil
|
||||
cb.RoomsByUser = nil
|
||||
cb.LiteByUser = nil
|
||||
|
||||
cbMu.Unlock()
|
||||
|
||||
if cb.LastErr != lastLoggedErr {
|
||||
@ -313,16 +432,9 @@ func startChaturbateOnlinePoller(store *ModelStore) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Erfolg: komplette Liste ersetzen + indices + fetchedAt setzen
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cb.RoomsByUser = indexRoomsByUser(rooms)
|
||||
cb.LiteByUser = indexLiteByUser(rooms)
|
||||
cb.FetchedAt = time.Now()
|
||||
_ = cbApplySnapshot(rooms)
|
||||
|
||||
cbMu.Unlock()
|
||||
|
||||
// ✅ Tags übernehmen ist teuer -> nur selten + im Hintergrund
|
||||
// Tags übernehmen ist teuer -> nur selten + im Hintergrund
|
||||
if cbModelStore != nil && len(rooms) > 0 {
|
||||
shouldFill := false
|
||||
|
||||
@ -418,7 +530,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var shows []string
|
||||
|
||||
// ---------------------------
|
||||
// Filter state (muss vor GET/POST da sein)
|
||||
// Filter state
|
||||
// ---------------------------
|
||||
var (
|
||||
allowedShow map[string]bool
|
||||
@ -449,7 +561,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
wantRefresh = req.Refresh
|
||||
|
||||
// ✅ neue Filter übernehmen (POST)
|
||||
genders := normalizeList(req.Gender)
|
||||
countries := normalizeList(req.Country)
|
||||
tagsAny := normalizeList(req.TagsAny)
|
||||
@ -461,7 +572,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
allowedCountry = toSet(countries)
|
||||
allowedTagsAny = toSet(tagsAny)
|
||||
|
||||
// normalize users
|
||||
seenU := map[string]bool{}
|
||||
for _, u := range req.Q {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
@ -473,7 +583,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
sort.Strings(users)
|
||||
|
||||
// normalize shows
|
||||
seenS := map[string]bool{}
|
||||
for _, s := range req.Show {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
@ -518,28 +627,24 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sort.Strings(shows)
|
||||
}
|
||||
|
||||
// ✅ gender=...
|
||||
qGender := strings.TrimSpace(r.URL.Query().Get("gender"))
|
||||
if qGender != "" {
|
||||
genders := normalizeList(strings.Split(qGender, ","))
|
||||
allowedGender = toSet(genders)
|
||||
}
|
||||
|
||||
// ✅ country=...
|
||||
qCountry := strings.TrimSpace(r.URL.Query().Get("country"))
|
||||
if qCountry != "" {
|
||||
countries := normalizeList(strings.Split(qCountry, ","))
|
||||
allowedCountry = toSet(countries)
|
||||
}
|
||||
|
||||
// ✅ tagsAny=...
|
||||
qTagsAny := strings.TrimSpace(r.URL.Query().Get("tagsAny"))
|
||||
if qTagsAny != "" {
|
||||
tagsAny := normalizeList(strings.Split(qTagsAny, ","))
|
||||
allowedTagsAny = toSet(tagsAny)
|
||||
}
|
||||
|
||||
// ✅ minUsers=123
|
||||
qMinUsers := strings.TrimSpace(r.URL.Query().Get("minUsers"))
|
||||
if qMinUsers != "" {
|
||||
if n, err := strconv.Atoi(qMinUsers); err == nil {
|
||||
@ -547,7 +652,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ isHD=1/true/yes
|
||||
qIsHD := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("isHD")))
|
||||
if qIsHD != "" {
|
||||
b := (qIsHD == "1" || qIsHD == "true" || qIsHD == "yes")
|
||||
@ -556,10 +660,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
allowedShow = toSet(shows)
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Ultra-wichtig: niemals die komplette Affiliate-Liste ausliefern.
|
||||
// Wenn keine Users angegeben sind -> leere Antwort (spart massiv CPU + JSON)
|
||||
// ---------------------------
|
||||
onlySpecificUsers := len(users) > 0
|
||||
|
||||
// ---------------------------
|
||||
@ -570,7 +670,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"users="+strings.Join(users, ","),
|
||||
"show="+strings.Join(keysOfSet(allowedShow), ","),
|
||||
|
||||
// ✅ neue Filter in den Key!
|
||||
"gender="+strings.Join(keysOfSet(allowedGender), ","),
|
||||
"country="+strings.Join(keysOfSet(allowedCountry), ","),
|
||||
"tagsAny="+strings.Join(keysOfSet(allowedTagsAny), ","),
|
||||
@ -609,23 +708,20 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Snapshot Cache (nur Lite-Index nutzen)
|
||||
// Snapshot Cache lesen (nur Lite)
|
||||
// ---------------------------
|
||||
cbMu.RLock()
|
||||
fetchedAt := cb.FetchedAt
|
||||
lastErr := cb.LastErr
|
||||
lastAttempt := cb.LastAttempt
|
||||
liteByUser := cb.LiteByUser // map[usernameLower]ChaturbateRoomLite
|
||||
liteByUser := cb.LiteByUser
|
||||
cbMu.RUnlock()
|
||||
|
||||
// ---------------------------
|
||||
// Persist "last seen online/offline" für explizit angefragte User
|
||||
// (nur wenn wir einen gültigen Snapshot haben)
|
||||
// ---------------------------
|
||||
if cbModelStore != nil && onlySpecificUsers && liteByUser != nil && !fetchedAt.IsZero() {
|
||||
seenAt := fetchedAt.UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Persistiert den tatsächlichen Snapshot-Status (unabhängig von Filtern)
|
||||
for _, u := range users {
|
||||
_, isOnline := liteByUser[u]
|
||||
_ = cbModelStore.SetLastSeenOnline("chaturbate.com", u, isOnline, seenAt)
|
||||
@ -633,17 +729,12 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// Refresh/Bootstrap-Strategie:
|
||||
// - Handler blockiert NICHT auf Remote-Fetch (Performance!)
|
||||
// - wenn refresh=true: triggert einen Fetch (best effort), aber liefert sofort Cache/leer zurück
|
||||
// - wenn Cache noch nie erfolgreich war: "warming up" + best-effort Bootstrap, mit Cooldown
|
||||
// Refresh/Bootstrap-Strategie
|
||||
// ---------------------------
|
||||
const bootstrapCooldown = 8 * time.Second
|
||||
|
||||
needBootstrap := fetchedAt.IsZero()
|
||||
shouldTriggerFetch :=
|
||||
wantRefresh ||
|
||||
(needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown)
|
||||
shouldTriggerFetch := wantRefresh || (needBootstrap && time.Since(lastAttempt) >= bootstrapCooldown)
|
||||
|
||||
if shouldTriggerFetch {
|
||||
cbRefreshMu.Lock()
|
||||
@ -653,12 +744,10 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cbRefreshInFlight = true
|
||||
cbRefreshMu.Unlock()
|
||||
|
||||
// attempt timestamp sofort setzen (damit 100 Requests nicht alle triggern)
|
||||
cbMu.Lock()
|
||||
cb.LastAttempt = time.Now()
|
||||
cbMu.Unlock()
|
||||
|
||||
// ✅ background fetch (nicht blockieren)
|
||||
go func() {
|
||||
defer func() {
|
||||
cbRefreshMu.Lock()
|
||||
@ -670,24 +759,20 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rooms, err := fetchChaturbateOnlineRooms(ctx)
|
||||
cancel()
|
||||
|
||||
cbMu.Lock()
|
||||
if err != nil {
|
||||
cbMu.Lock()
|
||||
cb.LastErr = err.Error()
|
||||
cb.Rooms = nil
|
||||
cb.RoomsByUser = nil
|
||||
cb.LiteByUser = nil
|
||||
// fetchedAt NICHT ändern (bleibt letzte erfolgreiche Zeit)
|
||||
} else {
|
||||
cb.LastErr = ""
|
||||
cb.Rooms = rooms
|
||||
cb.RoomsByUser = indexRoomsByUser(rooms)
|
||||
cb.LiteByUser = indexLiteByUser(rooms) // ✅ kleiner Index für Handler
|
||||
cb.FetchedAt = time.Now()
|
||||
cbMu.Unlock()
|
||||
return
|
||||
}
|
||||
cbMu.Unlock()
|
||||
|
||||
// Tags optional übernehmen (nur bei Erfolg)
|
||||
if cbModelStore != nil && err == nil && len(rooms) > 0 {
|
||||
_ = cbApplySnapshot(rooms)
|
||||
|
||||
if cbModelStore != nil && len(rooms) > 0 {
|
||||
cbModelStore.FillMissingTagsFromChaturbateOnline(rooms)
|
||||
}
|
||||
}()
|
||||
@ -741,7 +826,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return true
|
||||
}
|
||||
|
||||
// ✅ total = Anzahl online rooms (gefiltert), ohne sie auszuliefern
|
||||
total := 0
|
||||
if liteByUser != nil {
|
||||
noExtraFilters :=
|
||||
@ -783,7 +867,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// wenn noch nie erfolgreich gefetched: nicer error
|
||||
if needBootstrap && lastErr == "" {
|
||||
lastErr = "warming up"
|
||||
}
|
||||
@ -797,7 +880,7 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) {
|
||||
"count": len(outRooms),
|
||||
"total": total,
|
||||
"lastError": lastErr,
|
||||
"rooms": outRooms, // ✅ klein & schnell
|
||||
"rooms": outRooms,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(out)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -327,6 +327,11 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
// ----------------
|
||||
// Preview (MP4 teaser clips)
|
||||
// ----------------
|
||||
const (
|
||||
previewClipLenSec = 0.75
|
||||
previewMaxClips = 12
|
||||
)
|
||||
|
||||
var computedPreviewClips []previewClip
|
||||
|
||||
if previewBefore {
|
||||
@ -346,7 +351,7 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
|
||||
progress(thumbsW + 0.05)
|
||||
|
||||
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, 0.75, 12, func(r float64) {
|
||||
if err := generateTeaserClipsMP4WithProgress(genCtx, videoPath, previewPath, previewClipLenSec, previewMaxClips, func(r float64) {
|
||||
if r < 0 {
|
||||
r = 0
|
||||
}
|
||||
@ -372,16 +377,10 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
||||
return
|
||||
}
|
||||
|
||||
// muss identisch zu generateTeaserClipsMP4WithProgress Defaults sein
|
||||
// exakt dieselben Werte wie beim tatsächlichen Preview-Rendern
|
||||
opts := TeaserPreviewOptions{
|
||||
Segments: 18,
|
||||
SegmentDuration: 1.0,
|
||||
Width: 640,
|
||||
Preset: "veryfast",
|
||||
CRF: 21,
|
||||
Audio: true,
|
||||
AudioBitrate: "128k",
|
||||
UseVsync2: false,
|
||||
Segments: previewMaxClips,
|
||||
SegmentDuration: previewClipLenSec,
|
||||
}
|
||||
|
||||
starts, segDur, _ := computeTeaserStarts(meta.durSec, opts)
|
||||
|
||||
201
backend/main.go
201
backend/main.go
@ -170,207 +170,6 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl
|
||||
return w, h, fps, nil
|
||||
}
|
||||
|
||||
func metaJSONPathForAssetID(assetID string) (string, error) {
|
||||
root, err := generatedMetaRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("generated/meta root leer")
|
||||
}
|
||||
return filepath.Join(root, assetID, "meta.json"), nil
|
||||
}
|
||||
|
||||
func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var m videoMeta
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// nur akzeptieren wenn Datei identisch (damit wir nicht stale Werte zeigen)
|
||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Mindestvalidierung
|
||||
if m.DurationSeconds <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) {
|
||||
// assetID aus Dateiname
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// sanitize wie bei deinen generated Ordnern
|
||||
var err error
|
||||
assetID, err = sanitizeID(assetID)
|
||||
if err != nil || assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) valid meta vorhanden?
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) sonst neu erzeugen (mit Concurrency-Limit)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if durSem != nil {
|
||||
if err := durSem.Acquire(cctx); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer durSem.Release()
|
||||
}
|
||||
|
||||
// Dauer
|
||||
dur, derr := durationSecondsCached(cctx, fullPath)
|
||||
if derr != nil || dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Video props
|
||||
w, h, fps, perr := probeVideoProps(cctx, fullPath)
|
||||
if perr != nil {
|
||||
// width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich
|
||||
w, h, fps = 0, 0, 0
|
||||
}
|
||||
|
||||
// meta dir anlegen
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
|
||||
m := &videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
b = append(b, '\n')
|
||||
_ = atomicWriteFile(metaPath, b) // best effort
|
||||
|
||||
return m, true
|
||||
}
|
||||
|
||||
func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath string) {
|
||||
if job == nil {
|
||||
return
|
||||
}
|
||||
fullPath = strings.TrimSpace(fullPath)
|
||||
if fullPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Stat
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
// Größe immer mitgeben (macht Sort/Anzeige einfacher)
|
||||
if job.SizeBytes <= 0 {
|
||||
job.SizeBytes = fi.Size()
|
||||
}
|
||||
|
||||
// Meta.json lesen/erzeugen (best effort)
|
||||
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: komplettes Meta mitsenden
|
||||
job.Meta = m
|
||||
|
||||
// Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem)
|
||||
if job.DurationSeconds <= 0 && m.DurationSeconds > 0 {
|
||||
job.DurationSeconds = m.DurationSeconds
|
||||
}
|
||||
if job.VideoWidth <= 0 && m.VideoWidth > 0 {
|
||||
job.VideoWidth = m.VideoWidth
|
||||
}
|
||||
if job.VideoHeight <= 0 && m.VideoHeight > 0 {
|
||||
job.VideoHeight = m.VideoHeight
|
||||
}
|
||||
if job.FPS <= 0 && m.FPS > 0 {
|
||||
job.FPS = m.FPS
|
||||
}
|
||||
}
|
||||
|
||||
// ensureVideoMetaForFileBestEffort:
|
||||
// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile
|
||||
// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß:
|
||||
// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können.
|
||||
func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
|
||||
fullPath = strings.TrimSpace(fullPath)
|
||||
if fullPath == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg)
|
||||
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren
|
||||
dur := durationSecondsCacheOnly(fullPath, fi)
|
||||
if dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
|
||||
|
||||
// nochmal lesen/validieren
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (d *dummyResponseWriter) Header() http.Header {
|
||||
if d.h == nil {
|
||||
d.h = make(http.Header)
|
||||
|
||||
237
backend/meta.go
237
backend/meta.go
@ -2,6 +2,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@ -97,6 +98,207 @@ func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
|
||||
return m.DurationSeconds, true
|
||||
}
|
||||
|
||||
func metaJSONPathForAssetID(assetID string) (string, error) {
|
||||
root, err := generatedMetaRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("generated/meta root leer")
|
||||
}
|
||||
return filepath.Join(root, assetID, "meta.json"), nil
|
||||
}
|
||||
|
||||
func ensureVideoMetaForFile(ctx context.Context, fullPath string, fi os.FileInfo, sourceURL string) (*videoMeta, bool) {
|
||||
// assetID aus Dateiname
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// sanitize wie bei deinen generated Ordnern
|
||||
var err error
|
||||
assetID, err = sanitizeID(assetID)
|
||||
if err != nil || assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) valid meta vorhanden?
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) sonst neu erzeugen (mit Concurrency-Limit)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if durSem != nil {
|
||||
if err := durSem.Acquire(cctx); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer durSem.Release()
|
||||
}
|
||||
|
||||
// Dauer
|
||||
dur, derr := durationSecondsCached(cctx, fullPath)
|
||||
if derr != nil || dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Video props
|
||||
w, h, fps, perr := probeVideoProps(cctx, fullPath)
|
||||
if perr != nil {
|
||||
// width/height/fps dürfen 0 bleiben, duration ist aber trotzdem nützlich
|
||||
w, h, fps = 0, 0, 0
|
||||
}
|
||||
|
||||
// meta dir anlegen
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
|
||||
m := &videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
FileSize: fi.Size(),
|
||||
FileModUnix: fi.ModTime().Unix(),
|
||||
VideoWidth: w,
|
||||
VideoHeight: h,
|
||||
FPS: fps,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
b = append(b, '\n')
|
||||
_ = atomicWriteFile(metaPath, b) // best effort
|
||||
|
||||
return m, true
|
||||
}
|
||||
|
||||
func attachMetaToJobBestEffort(ctx context.Context, job *RecordJob, fullPath string) {
|
||||
if job == nil {
|
||||
return
|
||||
}
|
||||
fullPath = strings.TrimSpace(fullPath)
|
||||
if fullPath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Stat
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
// Größe immer mitgeben (macht Sort/Anzeige einfacher)
|
||||
if job.SizeBytes <= 0 {
|
||||
job.SizeBytes = fi.Size()
|
||||
}
|
||||
|
||||
// Meta.json lesen/erzeugen (best effort)
|
||||
m, ok := ensureVideoMetaForFileBestEffort(ctx, fullPath, job.SourceURL)
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: komplettes Meta mitsenden
|
||||
job.Meta = m
|
||||
|
||||
// Und zusätzlich die "Top-Level" Felder befüllen (für Frontend bequem)
|
||||
if job.DurationSeconds <= 0 && m.DurationSeconds > 0 {
|
||||
job.DurationSeconds = m.DurationSeconds
|
||||
}
|
||||
if job.VideoWidth <= 0 && m.VideoWidth > 0 {
|
||||
job.VideoWidth = m.VideoWidth
|
||||
}
|
||||
if job.VideoHeight <= 0 && m.VideoHeight > 0 {
|
||||
job.VideoHeight = m.VideoHeight
|
||||
}
|
||||
if job.FPS <= 0 && m.FPS > 0 {
|
||||
job.FPS = m.FPS
|
||||
}
|
||||
}
|
||||
|
||||
// ensureVideoMetaForFileBestEffort:
|
||||
// - versucht zuerst echtes Generieren (ffprobe/ffmpeg) via ensureVideoMetaForFile
|
||||
// - wenn das fehlschlägt, aber durationSecondsCacheOnly schon was weiß:
|
||||
// schreibt eine Duration-only meta.json, damit wir künftig "aus meta.json" lesen können.
|
||||
func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sourceURL string) (*videoMeta, bool) {
|
||||
fullPath = strings.TrimSpace(fullPath)
|
||||
if fullPath == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
fi, err := os.Stat(fullPath)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// 1) Normaler Weg: meta erzeugen/lesen (ffprobe/ffmpeg)
|
||||
if m, ok := ensureVideoMetaForFile(ctx, fullPath, fi, sourceURL); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
|
||||
// 2) Fallback: wenn wir Duration schon im RAM-Cache haben -> meta.json (Duration-only) persistieren
|
||||
dur := durationSecondsCacheOnly(fullPath, fi)
|
||||
if dur <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
||||
assetID := stripHotPrefix(strings.TrimSpace(stem))
|
||||
if assetID == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
metaPath, err := metaJSONPathForAssetID(assetID)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
|
||||
_ = writeVideoMetaDuration(metaPath, fi, dur, sourceURL)
|
||||
|
||||
// nochmal lesen/validieren
|
||||
if m, ok := readVideoMetaIfValid(metaPath, fi); ok && m != nil {
|
||||
return m, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func readVideoMetaIfValid(metaPath string, fi os.FileInfo) (*videoMeta, bool) {
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var m videoMeta
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if m.Version != 1 && m.Version != 2 {
|
||||
return nil, false
|
||||
}
|
||||
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
|
||||
return nil, false
|
||||
}
|
||||
if m.DurationSeconds <= 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &m, true
|
||||
}
|
||||
|
||||
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
||||
m, ok := readVideoMetaIfValid(metaPath, fi)
|
||||
if !ok || m == nil {
|
||||
@ -114,6 +316,11 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
var existing *videoMeta
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
|
||||
existing = old
|
||||
}
|
||||
|
||||
m := videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
@ -125,6 +332,15 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
|
||||
Resolution: formatResolution(w, h),
|
||||
SourceURL: strings.TrimSpace(sourceURL),
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
|
||||
// ✅ bestehende Preview-Daten behalten
|
||||
PreviewClips: nil,
|
||||
PreviewSprite: nil,
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
m.PreviewClips = existing.PreviewClips
|
||||
m.PreviewSprite = existing.PreviewSprite
|
||||
}
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
@ -138,6 +354,11 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
|
||||
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||
return nil
|
||||
}
|
||||
var existing *videoMeta
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil {
|
||||
existing = old
|
||||
}
|
||||
|
||||
m := videoMeta{
|
||||
Version: 2,
|
||||
DurationSeconds: dur,
|
||||
@ -151,6 +372,11 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
|
||||
PreviewClips: clips,
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
// ✅ vorhandenes Sprite (inkl. stepSeconds) nicht wegwerfen
|
||||
if existing != nil && existing.PreviewSprite != nil {
|
||||
m.PreviewSprite = existing.PreviewSprite
|
||||
}
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -189,6 +415,17 @@ func writeVideoMetaWithPreviewClipsAndSprite(
|
||||
UpdatedAtUnix: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if sprite == nil {
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && old.PreviewSprite != nil {
|
||||
m.PreviewSprite = old.PreviewSprite
|
||||
}
|
||||
}
|
||||
if len(clips) == 0 {
|
||||
if old, ok := readVideoMetaIfValid(metaPath, fi); ok && old != nil && len(old.PreviewClips) > 0 {
|
||||
m.PreviewClips = old.PreviewClips
|
||||
}
|
||||
}
|
||||
|
||||
buf, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ✅ umbenannt, damit es nicht mit models.go kollidiert
|
||||
@ -157,8 +158,8 @@ func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult,
|
||||
}
|
||||
|
||||
get := func(key string) string {
|
||||
i := idx[key]
|
||||
if i < 0 || i >= len(rec) {
|
||||
i, ok := idx[key]
|
||||
if !ok || i < 0 || i >= len(rec) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(rec[i])
|
||||
@ -240,18 +241,111 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
modelsWriteJSON(w, http.StatusOK, store.Meta())
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||||
modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
modelsWriteJSON(w, http.StatusOK, store.List())
|
||||
})
|
||||
|
||||
// ✅ Profilbild-Blob aus DB ausliefern
|
||||
mux.HandleFunc("/api/models/image", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id fehlt"})
|
||||
return
|
||||
}
|
||||
|
||||
mime, data, ok, err := store.GetProfileImageByID(id)
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !ok || len(data) == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
_, _ = w.Write(data)
|
||||
})
|
||||
|
||||
// ✅ Profilbild hochladen/ersetzen (Blob + URL speichern)
|
||||
mux.HandleFunc("/api/models/profile-image", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
|
||||
host := strings.TrimSpace(r.FormValue("host"))
|
||||
modelKey := strings.TrimSpace(r.FormValue("modelKey"))
|
||||
sourceURL := strings.TrimSpace(r.FormValue("sourceUrl"))
|
||||
|
||||
if modelKey == "" {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"})
|
||||
return
|
||||
}
|
||||
|
||||
f, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(f, 8<<20))
|
||||
if err != nil || len(data) == 0 {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid image"})
|
||||
return
|
||||
}
|
||||
|
||||
mime := http.DetectContentType(data)
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file is not an image"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SetProfileImage(host, modelKey, sourceURL, mime, data, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
modelsWriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
m, err := store.EnsureByHostModelKey(host, modelKey)
|
||||
if err != nil {
|
||||
modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
modelsWriteJSON(w, http.StatusOK, m)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/models/upsert", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
@ -278,7 +372,6 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
})
|
||||
|
||||
// ✅ NEU: Ensure-Endpoint (für QuickActions aus FinishedDownloads)
|
||||
// Erst versucht er ein bestehendes Model via modelKey zu finden, sonst legt er ein "manual" Model an.
|
||||
mux.HandleFunc("/api/models/ensure", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
@ -379,11 +472,10 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Wenn ein Model weder beobachtet noch favorisiert/geliked ist, fliegt es aus dem Store.
|
||||
// (Damit bleibt der Store „sauber“ und ModelsTab listet nur relevante Einträge.)
|
||||
// ✅ Cleanup wenn kein relevanter Flag mehr gesetzt ist
|
||||
likedOn := (m.Liked != nil && *m.Liked)
|
||||
if !m.Watching && !m.Favorite && !likedOn {
|
||||
_ = store.Delete(m.ID) // best-effort: Patch war erfolgreich, Delete darf hier nicht „fatal“ sein
|
||||
_ = store.Delete(m.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -27,6 +28,10 @@ type StoredModel struct {
|
||||
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
|
||||
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
|
||||
|
||||
ProfileImageURL string `json:"profileImageUrl,omitempty"`
|
||||
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
|
||||
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
|
||||
|
||||
Watching bool `json:"watching"`
|
||||
Favorite bool `json:"favorite"`
|
||||
Hot bool `json:"hot"`
|
||||
@ -60,9 +65,9 @@ type ParsedModelDTO struct {
|
||||
}
|
||||
|
||||
type ModelFlagsPatch struct {
|
||||
Host string `json:"host,omitempty"` // ✅ neu
|
||||
ModelKey string `json:"modelKey,omitempty"` // ✅ wenn id fehlt
|
||||
ID string `json:"id,omitempty"` // ✅ optional
|
||||
Host string `json:"host,omitempty"`
|
||||
ModelKey string `json:"modelKey,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
Watched *bool `json:"watched,omitempty"`
|
||||
Favorite *bool `json:"favorite,omitempty"`
|
||||
@ -149,8 +154,6 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
// EnsureByModelKey:
|
||||
// - liefert ein bestehendes Model (best match) wenn vorhanden
|
||||
// - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false)
|
||||
// Dadurch funktionieren QuickActions (Like/Favorite) auch bei fertigen Videos,
|
||||
// bei denen keine SourceURL mehr vorhanden ist.
|
||||
func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return StoredModel{}, err
|
||||
@ -161,8 +164,6 @@ func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
|
||||
return StoredModel{}, errors.New("modelKey fehlt")
|
||||
}
|
||||
|
||||
// Erst schauen ob es das Model schon gibt (egal welcher Host)
|
||||
// Erst schauen ob es das Model schon gibt (egal welcher Host)
|
||||
var existingID string
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id
|
||||
@ -183,7 +184,6 @@ func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
|
||||
return StoredModel{}, err
|
||||
}
|
||||
|
||||
// Neu anlegen als "manual" (is_url = 0), input = modelKey (NOT NULL)
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
id := canonicalID("", key)
|
||||
|
||||
@ -260,8 +260,7 @@ WHERE lower(trim(host)) = 'chaturbate.com'
|
||||
}
|
||||
|
||||
// Backwards compatible:
|
||||
// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db"
|
||||
// und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt.
|
||||
// - wenn du ".json" übergibst, wird daraus automatisch ".db"
|
||||
func NewModelStore(path string) *ModelStore {
|
||||
path = strings.TrimSpace(path)
|
||||
|
||||
@ -271,7 +270,7 @@ func NewModelStore(path string) *ModelStore {
|
||||
|
||||
if strings.HasSuffix(lower, ".json") {
|
||||
legacy = path
|
||||
dbPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".db" // z.B. models_store.db
|
||||
dbPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".db"
|
||||
} else if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") || strings.HasSuffix(lower, ".sqlite3") {
|
||||
legacy = filepath.Join(filepath.Dir(path), "models_store.json")
|
||||
}
|
||||
@ -282,8 +281,6 @@ func NewModelStore(path string) *ModelStore {
|
||||
}
|
||||
}
|
||||
|
||||
// main.go ruft aktuell store.Load() auf :contentReference[oaicite:4]{index=4}
|
||||
// -> wir lassen Load() als Alias für Init() drin.
|
||||
func (s *ModelStore) Load() error { return s.ensureInit() }
|
||||
|
||||
func (s *ModelStore) ensureInit() error {
|
||||
@ -305,17 +302,14 @@ func (s *ModelStore) init() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// SQLite am besten single-conn im Server-Prozess
|
||||
|
||||
db.SetMaxOpenConns(5)
|
||||
db.SetMaxIdleConns(5)
|
||||
_, _ = db.Exec(`PRAGMA busy_timeout = 2500;`)
|
||||
|
||||
// Pragmas (einzeln ausführen)
|
||||
_, _ = db.Exec(`PRAGMA foreign_keys = ON;`)
|
||||
_, _ = db.Exec(`PRAGMA journal_mode = WAL;`)
|
||||
_, _ = db.Exec(`PRAGMA synchronous = NORMAL;`)
|
||||
|
||||
// ✅ zuerst Schema/Columns auf "db" erstellen
|
||||
if err := createModelsSchema(db); err != nil {
|
||||
_ = db.Close()
|
||||
return err
|
||||
@ -325,17 +319,14 @@ func (s *ModelStore) init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ erst danach in den Store übernehmen
|
||||
s.db = db
|
||||
|
||||
// 1x Migration: wenn DB leer ist und Legacy JSON existiert
|
||||
if s.legacyJSONPath != "" {
|
||||
if err := s.migrateFromJSONIfEmpty(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ beim Einlesen normalisieren
|
||||
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -358,6 +349,11 @@ CREATE TABLE IF NOT EXISTS models (
|
||||
biocontext_json TEXT,
|
||||
biocontext_fetched_at TEXT,
|
||||
|
||||
profile_image_url TEXT,
|
||||
profile_image_mime TEXT,
|
||||
profile_image_blob BLOB,
|
||||
profile_image_updated_at TEXT,
|
||||
|
||||
last_seen_online INTEGER NULL, -- NULL/0/1
|
||||
last_seen_online_at TEXT,
|
||||
|
||||
@ -411,7 +407,7 @@ func ensureModelsColumns(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Biocontext (persistente Bio-Infos)
|
||||
// ✅ Biocontext
|
||||
if !cols["biocontext_json"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_json TEXT;`); err != nil {
|
||||
return err
|
||||
@ -423,7 +419,29 @@ func ensureModelsColumns(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Last seen online/offline (persistente Presence-Infos)
|
||||
// ✅ Profile image columns
|
||||
if !cols["profile_image_url"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_url TEXT;`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !cols["profile_image_mime"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_mime TEXT;`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !cols["profile_image_blob"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_blob BLOB;`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !cols["profile_image_updated_at"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_updated_at TEXT;`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Last seen online/offline
|
||||
if !cols["last_seen_online"] {
|
||||
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_seen_online INTEGER NULL;`); err != nil {
|
||||
return err
|
||||
@ -475,16 +493,6 @@ func ptrLikedFromNull(n sql.NullInt64) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
func nullBoolToNullInt64(p *bool) sql.NullInt64 {
|
||||
if p == nil {
|
||||
return sql.NullInt64{Valid: false}
|
||||
}
|
||||
if *p {
|
||||
return sql.NullInt64{Valid: true, Int64: 1}
|
||||
}
|
||||
return sql.NullInt64{Valid: true, Int64: 0}
|
||||
}
|
||||
|
||||
func ptrBoolFromNullInt64(n sql.NullInt64) *bool {
|
||||
if !n.Valid {
|
||||
return nil
|
||||
@ -493,10 +501,150 @@ func ptrBoolFromNullInt64(n sql.NullInt64) *bool {
|
||||
return &v
|
||||
}
|
||||
|
||||
// --- Biocontext Cache (persistente Bio-Infos aus Chaturbate) ---
|
||||
// --- Profile image cache ---
|
||||
|
||||
// SetProfileImage speichert Bild-URL + MIME + Blob.
|
||||
// Legt den Datensatz bei Bedarf minimal an.
|
||||
func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, data []byte, updatedAt 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")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errors.New("image data fehlt")
|
||||
}
|
||||
|
||||
src := strings.TrimSpace(sourceURL)
|
||||
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||
if mime == "" || mime == "application/octet-stream" {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.TrimSpace(detected) != "" {
|
||||
mime = detected
|
||||
}
|
||||
}
|
||||
if mime == "" {
|
||||
mime = "image/jpeg"
|
||||
}
|
||||
|
||||
ts := strings.TrimSpace(updatedAt)
|
||||
if ts == "" {
|
||||
ts = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Erst Update versuchen
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE models
|
||||
SET profile_image_url=?, profile_image_mime=?, profile_image_blob=?, profile_image_updated_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, src, mime, data, ts, now, host, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kein Auto-Insert: Profilbild nur für bereits bestehende Models speichern.
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProfileImageURLOnly speichert nur die letzte bekannte Bild-URL (+Zeit), ohne Blob.
|
||||
// Praktisch als Fallback, wenn Download fehlschlägt.
|
||||
func (s *ModelStore) SetProfileImageURLOnly(host, modelKey, sourceURL, updatedAt string) error {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host = canonicalHost(host)
|
||||
key := strings.TrimSpace(modelKey)
|
||||
src := strings.TrimSpace(sourceURL)
|
||||
if host == "" || key == "" {
|
||||
return errors.New("host/modelKey fehlt")
|
||||
}
|
||||
if src == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ts := strings.TrimSpace(updatedAt)
|
||||
if ts == "" {
|
||||
ts = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE models
|
||||
SET profile_image_url=?, profile_image_updated_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, src, ts, now, host, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aff, _ := res.RowsAffected()
|
||||
if aff > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kein Auto-Insert: Bild-URL nur für bereits bestehende Models speichern.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ModelStore) GetProfileImageByID(id string) (mime string, data []byte, ok bool, err error) {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", nil, false, errors.New("id fehlt")
|
||||
}
|
||||
|
||||
var mimeNS sql.NullString
|
||||
var blob []byte
|
||||
err = s.db.QueryRow(`
|
||||
SELECT profile_image_mime, profile_image_blob
|
||||
FROM models
|
||||
WHERE id = ?
|
||||
LIMIT 1;
|
||||
`, id).Scan(&mimeNS, &blob)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
if len(blob) == 0 {
|
||||
return "", nil, false, nil
|
||||
}
|
||||
|
||||
m := strings.TrimSpace(mimeNS.String)
|
||||
if m == "" {
|
||||
m = http.DetectContentType(blob)
|
||||
if m == "" {
|
||||
m = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
return m, blob, true, nil
|
||||
}
|
||||
|
||||
// --- Biocontext Cache ---
|
||||
|
||||
// GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel).
|
||||
// ok=false wenn nichts gespeichert ist.
|
||||
func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetchedAt string, ok bool, err error) {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return "", "", false, err
|
||||
@ -531,8 +679,6 @@ func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetch
|
||||
return val, strings.TrimSpace(ts.String), true, nil
|
||||
}
|
||||
|
||||
// SetBioContext speichert/aktualisiert das Biocontext-JSON dauerhaft in der DB.
|
||||
// Es legt das Model (host+modelKey) bei Bedarf minimal an.
|
||||
func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return err
|
||||
@ -551,11 +697,11 @@ func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) er
|
||||
defer s.mu.Unlock()
|
||||
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE models
|
||||
SET biocontext_json=?, biocontext_fetched_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, js, ts, now, host, key)
|
||||
UPDATE models
|
||||
SET biocontext_json=?, biocontext_fetched_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, js, ts, now, host, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -565,34 +711,11 @@ func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// Model existiert noch nicht -> minimal anlegen (als URL)
|
||||
id := canonicalID(host, key)
|
||||
input := "https://" + host + "/" + key + "/"
|
||||
path := "/" + key + "/"
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO models (
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags,last_stream,
|
||||
biocontext_json,biocontext_fetched_at,
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
biocontext_json=excluded.biocontext_json,
|
||||
biocontext_fetched_at=excluded.biocontext_fetched_at,
|
||||
updated_at=excluded.updated_at;
|
||||
`, id, input, int64(1), host, path, key,
|
||||
"", "",
|
||||
js, ts,
|
||||
int64(0), int64(0), int64(0), int64(0), nil,
|
||||
now, now,
|
||||
)
|
||||
return err
|
||||
// Kein Auto-Insert: Biocontext nur für vorhandene Models.
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastSeenOnline speichert den zuletzt bekannten Online/Offline-Status (+ Zeit)
|
||||
// dauerhaft in der DB. Legt das Model (host+modelKey) bei Bedarf minimal an.
|
||||
// SetLastSeenOnline speichert Online/Offline Status
|
||||
func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenAt string) error {
|
||||
if err := s.ensureInit(); err != nil {
|
||||
return err
|
||||
@ -617,13 +740,12 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Erst versuchen, vorhandenes Model zu aktualisieren
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE models
|
||||
SET last_seen_online=?, last_seen_online_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, onlineArg, ts, now, host, key)
|
||||
UPDATE models
|
||||
SET last_seen_online=?, last_seen_online_at=?, updated_at=?
|
||||
WHERE lower(trim(host)) = lower(trim(?))
|
||||
AND lower(trim(model_key)) = lower(trim(?));
|
||||
`, onlineArg, ts, now, host, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -633,37 +755,12 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA
|
||||
return nil
|
||||
}
|
||||
|
||||
// Falls noch kein Model existiert: minimal anlegen
|
||||
id := canonicalID(host, key)
|
||||
input := "https://" + host + "/" + key + "/"
|
||||
path := "/" + key + "/"
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO models (
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags,last_stream,
|
||||
biocontext_json,biocontext_fetched_at,
|
||||
last_seen_online,last_seen_online_at,
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
last_seen_online=excluded.last_seen_online,
|
||||
last_seen_online_at=excluded.last_seen_online_at,
|
||||
updated_at=excluded.updated_at;
|
||||
`,
|
||||
id, input, int64(1), host, path, key,
|
||||
"", "",
|
||||
nil, nil,
|
||||
onlineArg, ts,
|
||||
int64(0), int64(0), int64(0), int64(0), nil,
|
||||
now, now,
|
||||
)
|
||||
return err
|
||||
// Wichtig: Keine Auto-Erzeugung durch Online-Poller.
|
||||
// Nur bereits manuell/importiert vorhandene Models werden aktualisiert.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ModelStore) migrateFromJSONIfEmpty() error {
|
||||
// DB leer?
|
||||
var cnt int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(1) FROM models;`).Scan(&cnt); err != nil {
|
||||
return err
|
||||
@ -672,7 +769,6 @@ func (s *ModelStore) migrateFromJSONIfEmpty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Legacy JSON vorhanden?
|
||||
b, err := os.ReadFile(s.legacyJSONPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@ -698,19 +794,22 @@ func (s *ModelStore) migrateFromJSONIfEmpty() error {
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// ✅ FIX: 15 Spalten => 15 Platzhalter
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO models (
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags,last_stream,
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
input=excluded.input,
|
||||
is_url=excluded.is_url,
|
||||
host=excluded.host,
|
||||
path=excluded.path,
|
||||
model_key=excluded.model_key,
|
||||
tags=excluded.tags,
|
||||
last_stream=excluded.last_stream,
|
||||
updated_at=excluded.updated_at;
|
||||
`)
|
||||
if err != nil {
|
||||
@ -727,7 +826,6 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
continue
|
||||
}
|
||||
|
||||
// alte IDs (oft nur modelKey) werden auf host:modelKey normalisiert
|
||||
id := canonicalID(host, modelKey)
|
||||
|
||||
created := strings.TrimSpace(m.CreatedAt)
|
||||
@ -754,6 +852,8 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
host,
|
||||
m.Path,
|
||||
modelKey,
|
||||
m.Tags,
|
||||
m.LastStream,
|
||||
boolToInt(m.Watching),
|
||||
boolToInt(m.Favorite),
|
||||
boolToInt(m.Hot),
|
||||
@ -775,7 +875,6 @@ func bytesTrimSpace(b []byte) []byte {
|
||||
}
|
||||
|
||||
func (s *ModelStore) normalizeNameOnlyChaturbate() error {
|
||||
// Kandidaten: is_url=0 UND input==model_key UND host leer oder schon chaturbate
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
id, model_key,
|
||||
@ -835,7 +934,6 @@ WHERE is_url = 0
|
||||
newInput := "https://" + host + "/" + it.key + "/"
|
||||
newPath := "/" + it.key + "/"
|
||||
|
||||
// Ziel-Datensatz: wenn bereits chaturbate.com:<key> existiert, dorthin mergen
|
||||
var targetID string
|
||||
err := tx.QueryRow(`
|
||||
SELECT id
|
||||
@ -859,7 +957,6 @@ LIMIT 1;
|
||||
likedArg = nil
|
||||
}
|
||||
|
||||
// Wenn es keinen Ziel-Datensatz gibt: neu anlegen mit canonical ID
|
||||
if targetID == "" {
|
||||
targetID = canonicalID(host, it.key)
|
||||
|
||||
@ -880,7 +977,6 @@ INSERT INTO models (
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Ziel existiert: Flags mergen + fehlende Felder auffüllen
|
||||
_, err = tx.Exec(`
|
||||
UPDATE models SET
|
||||
input = CASE
|
||||
@ -915,7 +1011,6 @@ WHERE id = ?;
|
||||
}
|
||||
}
|
||||
|
||||
// alten "manual" Datensatz löschen (nur wenn anderer Ziel-Datensatz)
|
||||
if it.oldID != targetID {
|
||||
if _, err := tx.Exec(`DELETE FROM models WHERE id=?;`, it.oldID); err != nil {
|
||||
return err
|
||||
@ -932,15 +1027,18 @@ func (s *ModelStore) List() []StoredModel {
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(`
|
||||
SELECT
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags, COALESCE(last_stream,''),
|
||||
last_seen_online, COALESCE(last_seen_online_at,''),
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
FROM models
|
||||
ORDER BY updated_at DESC;
|
||||
`)
|
||||
SELECT
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags, COALESCE(last_stream,''),
|
||||
last_seen_online, COALESCE(last_seen_online_at,''),
|
||||
COALESCE(profile_image_url,''),
|
||||
COALESCE(profile_image_updated_at,''),
|
||||
CASE WHEN profile_image_blob IS NOT NULL AND 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;
|
||||
`)
|
||||
if err != nil {
|
||||
return []StoredModel{}
|
||||
}
|
||||
@ -950,23 +1048,31 @@ func (s *ModelStore) List() []StoredModel {
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
|
||||
isURL, watching, favorite, hot, keep int64
|
||||
liked sql.NullInt64
|
||||
lastSeenOnline sql.NullInt64
|
||||
lastSeenOnlineAt string
|
||||
id, input, host, path, modelKey, tags, lastStream string
|
||||
createdAt, updatedAt string
|
||||
|
||||
isURL, watching, favorite, hot, keep int64
|
||||
liked sql.NullInt64
|
||||
lastSeenOnline sql.NullInt64
|
||||
lastSeenOnlineAt string
|
||||
|
||||
profileImageURL string
|
||||
profileImageUpdatedAt string
|
||||
hasProfileImage int64
|
||||
)
|
||||
|
||||
if err := rows.Scan(
|
||||
&id, &input, &isURL, &host, &path, &modelKey,
|
||||
&tags, &lastStream,
|
||||
&lastSeenOnline, &lastSeenOnlineAt,
|
||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||
&watching, &favorite, &hot, &keep, &liked,
|
||||
&createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, StoredModel{
|
||||
m := StoredModel{
|
||||
ID: id,
|
||||
Input: input,
|
||||
IsURL: isURL != 0,
|
||||
@ -984,7 +1090,16 @@ func (s *ModelStore) List() []StoredModel {
|
||||
Liked: ptrLikedFromNull(liked),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
})
|
||||
|
||||
ProfileImageURL: profileImageURL,
|
||||
ProfileImageUpdatedAt: profileImageUpdatedAt,
|
||||
}
|
||||
|
||||
if hasProfileImage != 0 {
|
||||
m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id)
|
||||
}
|
||||
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out
|
||||
@ -1085,27 +1200,27 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO models (
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags,last_stream,
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
input=excluded.input,
|
||||
is_url=excluded.is_url,
|
||||
host=excluded.host,
|
||||
path=excluded.path,
|
||||
model_key=excluded.model_key,
|
||||
updated_at=excluded.updated_at;
|
||||
`,
|
||||
INSERT INTO models (
|
||||
id,input,is_url,host,path,model_key,
|
||||
tags,last_stream,
|
||||
watching,favorite,hot,keep,liked,
|
||||
created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
input=excluded.input,
|
||||
is_url=excluded.is_url,
|
||||
host=excluded.host,
|
||||
path=excluded.path,
|
||||
model_key=excluded.model_key,
|
||||
updated_at=excluded.updated_at;
|
||||
`,
|
||||
id,
|
||||
u.String(),
|
||||
int64(1),
|
||||
host,
|
||||
p.Path,
|
||||
modelKey,
|
||||
"", "", // ✅ tags, last_stream
|
||||
"", "",
|
||||
int64(0), int64(0), int64(0), int64(0), nil,
|
||||
now,
|
||||
now,
|
||||
@ -1128,7 +1243,6 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// aktuelle Flags lesen
|
||||
var (
|
||||
watching, favorite, hot, keep int64
|
||||
liked sql.NullInt64
|
||||
@ -1142,28 +1256,21 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
|
||||
return StoredModel{}, err
|
||||
}
|
||||
|
||||
// ✅ watched -> watching (DB)
|
||||
if patch.Watched != nil {
|
||||
watching = boolToInt(*patch.Watched)
|
||||
}
|
||||
|
||||
if patch.Favorite != nil {
|
||||
favorite = boolToInt(*patch.Favorite)
|
||||
}
|
||||
|
||||
// ✅ liked ist true/false (kein ClearLiked mehr)
|
||||
if patch.Liked != nil {
|
||||
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
|
||||
}
|
||||
|
||||
// ✅ Exklusivität serverseitig (robust):
|
||||
// - liked=true => favorite=false
|
||||
// - favorite=true => liked=false (nicht NULL)
|
||||
// Exklusivität
|
||||
if patch.Liked != nil && *patch.Liked {
|
||||
favorite = int64(0)
|
||||
}
|
||||
if patch.Favorite != nil && *patch.Favorite {
|
||||
// Wenn Frontend nicht explizit liked=true sendet, force liked=false
|
||||
if patch.Liked == nil || !*patch.Liked {
|
||||
liked = sql.NullInt64{Valid: true, Int64: 0}
|
||||
}
|
||||
@ -1225,7 +1332,6 @@ func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string,
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// kind: "favorite" | "liked"
|
||||
fav := int64(0)
|
||||
var likedArg any = nil
|
||||
if kind == "favorite" {
|
||||
@ -1238,7 +1344,6 @@ func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string,
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// exists?
|
||||
inserted := false
|
||||
var dummy int
|
||||
err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=? LIMIT 1;`, id).Scan(&dummy)
|
||||
@ -1283,11 +1388,17 @@ ON CONFLICT(id) DO UPDATE SET
|
||||
|
||||
func (s *ModelStore) getByID(id string) (StoredModel, error) {
|
||||
var (
|
||||
input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
|
||||
isURL, watching, favorite, hot, keep int64
|
||||
liked sql.NullInt64
|
||||
lastSeenOnlineAt string
|
||||
lastSeenOnline sql.NullInt64
|
||||
input, host, path, modelKey, tags, lastStream string
|
||||
createdAt, updatedAt string
|
||||
|
||||
isURL, watching, favorite, hot, keep int64
|
||||
liked sql.NullInt64
|
||||
lastSeenOnline sql.NullInt64
|
||||
lastSeenOnlineAt string
|
||||
|
||||
profileImageURL string
|
||||
profileImageUpdatedAt string
|
||||
hasProfileImage int64
|
||||
)
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
@ -1295,6 +1406,9 @@ SELECT
|
||||
input,is_url,host,path,model_key,
|
||||
tags, COALESCE(last_stream,''),
|
||||
last_seen_online, COALESCE(last_seen_online_at,''),
|
||||
COALESCE(profile_image_url,''),
|
||||
COALESCE(profile_image_updated_at,''),
|
||||
CASE WHEN profile_image_blob IS NOT NULL AND 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
|
||||
@ -1303,6 +1417,7 @@ WHERE id=?;
|
||||
&input, &isURL, &host, &path, &modelKey,
|
||||
&tags, &lastStream,
|
||||
&lastSeenOnline, &lastSeenOnlineAt,
|
||||
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
|
||||
&watching, &favorite, &hot, &keep, &liked,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
@ -1313,7 +1428,7 @@ WHERE id=?;
|
||||
return StoredModel{}, err
|
||||
}
|
||||
|
||||
return StoredModel{
|
||||
m := StoredModel{
|
||||
ID: id,
|
||||
Input: input,
|
||||
IsURL: isURL != 0,
|
||||
@ -1331,5 +1446,14 @@ WHERE id=?;
|
||||
Liked: ptrLikedFromNull(liked),
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
|
||||
ProfileImageURL: profileImageURL,
|
||||
ProfileImageUpdatedAt: profileImageUpdatedAt,
|
||||
}
|
||||
|
||||
if hasProfileImage != 0 {
|
||||
m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
Binary file not shown.
@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
@ -35,15 +36,25 @@ type doneListResponse struct {
|
||||
PageSize int `json:"pageSize,omitempty"`
|
||||
}
|
||||
|
||||
type previewSpriteMetaResp struct {
|
||||
Exists bool `json:"exists"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
StepSeconds float64 `json:"stepSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type doneMetaFileResp struct {
|
||||
File string `json:"file"`
|
||||
MetaExists bool `json:"metaExists"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
File string `json:"file"`
|
||||
MetaExists bool `json:"metaExists"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type doneMetaResp struct {
|
||||
@ -121,6 +132,383 @@ func isSafeBasename(name string) bool {
|
||||
return filepath.Base(name) == name
|
||||
}
|
||||
|
||||
func intFromAny(v any) (int, bool) {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x, true
|
||||
case int8:
|
||||
return int(x), true
|
||||
case int16:
|
||||
return int(x), true
|
||||
case int32:
|
||||
return int(x), true
|
||||
case int64:
|
||||
return int(x), true
|
||||
case uint:
|
||||
return int(x), true
|
||||
case uint8:
|
||||
return int(x), true
|
||||
case uint16:
|
||||
return int(x), true
|
||||
case uint32:
|
||||
return int(x), true
|
||||
case uint64:
|
||||
return int(x), true
|
||||
case float32:
|
||||
return int(x), true
|
||||
case float64:
|
||||
return int(x), true
|
||||
case json.Number:
|
||||
if i, err := x.Int64(); err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
if f, err := x.Float64(); err == nil {
|
||||
return int(f), true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if i, err := strconv.Atoi(s); err == nil {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func floatFromAny(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float32:
|
||||
return float64(x), true
|
||||
case float64:
|
||||
return x, true
|
||||
case int:
|
||||
return float64(x), true
|
||||
case int8:
|
||||
return float64(x), true
|
||||
case int16:
|
||||
return float64(x), true
|
||||
case int32:
|
||||
return float64(x), true
|
||||
case int64:
|
||||
return float64(x), true
|
||||
case uint:
|
||||
return float64(x), true
|
||||
case uint8:
|
||||
return float64(x), true
|
||||
case uint16:
|
||||
return float64(x), true
|
||||
case uint32:
|
||||
return float64(x), true
|
||||
case uint64:
|
||||
return float64(x), true
|
||||
case json.Number:
|
||||
if f, err := x.Float64(); err == nil {
|
||||
return f, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
type previewSpriteMetaFileInfo struct {
|
||||
Count int
|
||||
Cols int
|
||||
Rows int
|
||||
StepSeconds float64
|
||||
}
|
||||
|
||||
func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileInfo, bool) {
|
||||
var out previewSpriteMetaFileInfo
|
||||
|
||||
b, err := os.ReadFile(metaPath)
|
||||
if err != nil || len(b) == 0 {
|
||||
return out, false
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
return out, false
|
||||
}
|
||||
|
||||
ps, ok := m["previewSprite"].(map[string]any)
|
||||
if !ok || ps == nil {
|
||||
return out, false
|
||||
}
|
||||
|
||||
if n, ok := intFromAny(ps["count"]); ok && n > 0 {
|
||||
out.Count = n
|
||||
} else if n, ok := intFromAny(ps["frames"]); ok && n > 0 {
|
||||
out.Count = n
|
||||
} else if n, ok := intFromAny(ps["imageCount"]); ok && n > 0 {
|
||||
out.Count = n
|
||||
}
|
||||
|
||||
if n, ok := intFromAny(ps["cols"]); ok && n > 0 {
|
||||
out.Cols = n
|
||||
}
|
||||
if n, ok := intFromAny(ps["rows"]); ok && n > 0 {
|
||||
out.Rows = n
|
||||
}
|
||||
|
||||
if f, ok := floatFromAny(ps["stepSeconds"]); ok && f > 0 {
|
||||
out.StepSeconds = f
|
||||
} else if f, ok := floatFromAny(ps["step"]); ok && f > 0 {
|
||||
out.StepSeconds = f
|
||||
} else if f, ok := floatFromAny(ps["intervalSeconds"]); ok && f > 0 {
|
||||
out.StepSeconds = f
|
||||
}
|
||||
|
||||
// gültig, wenn mindestens count oder grid vorhanden ist
|
||||
if out.Count > 0 || (out.Cols > 0 && out.Rows > 0) {
|
||||
return out, true
|
||||
}
|
||||
return out, false
|
||||
}
|
||||
|
||||
func previewSpriteTruthForID(id string) previewSpriteMetaResp {
|
||||
out := previewSpriteMetaResp{Exists: false}
|
||||
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
||||
return out
|
||||
}
|
||||
|
||||
metaPath, err := generatedMetaFile(id)
|
||||
if err != nil || strings.TrimSpace(metaPath) == "" {
|
||||
return out
|
||||
}
|
||||
|
||||
genDir := filepath.Dir(metaPath)
|
||||
spriteFile := filepath.Join(genDir, "preview-sprite.webp")
|
||||
|
||||
fi, err := os.Stat(spriteFile)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
// ✅ echte Datei existiert
|
||||
out.Exists = true
|
||||
out.Path = "/api/preview-sprite/" + url.PathEscape(id)
|
||||
|
||||
// Meta-Felder best-effort aus meta.json lesen
|
||||
if ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath); ok {
|
||||
if ps.Count > 0 {
|
||||
out.Count = ps.Count
|
||||
}
|
||||
if ps.Cols > 0 {
|
||||
out.Cols = ps.Cols
|
||||
}
|
||||
if ps.Rows > 0 {
|
||||
out.Rows = ps.Rows
|
||||
}
|
||||
if ps.StepSeconds > 0 {
|
||||
out.StepSeconds = ps.StepSeconds
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
resp.PreviewSprite = previewSpriteTruthForID(id)
|
||||
}
|
||||
|
||||
func metaMapFromAny(v any) map[string]any {
|
||||
out := map[string]any{}
|
||||
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return out
|
||||
|
||||
case map[string]any:
|
||||
for k, val := range x {
|
||||
out[k] = val
|
||||
}
|
||||
return out
|
||||
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(s))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err == nil && m != nil {
|
||||
return m
|
||||
}
|
||||
return out
|
||||
|
||||
case []byte:
|
||||
if len(x) == 0 {
|
||||
return out
|
||||
}
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(x)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err == nil && m != nil {
|
||||
return m
|
||||
}
|
||||
return out
|
||||
|
||||
case json.RawMessage:
|
||||
if len(x) == 0 {
|
||||
return out
|
||||
}
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(x)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err == nil && m != nil {
|
||||
return m
|
||||
}
|
||||
return out
|
||||
|
||||
default:
|
||||
// best effort: unbekannten Typ in map re-hydraten
|
||||
b, err := json.Marshal(x)
|
||||
if err != nil || len(b) == 0 {
|
||||
return out
|
||||
}
|
||||
var m map[string]any
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&m); err == nil && m != nil {
|
||||
return m
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func setStructFieldJSONMap(fv reflect.Value, m map[string]any) {
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
return
|
||||
}
|
||||
|
||||
// JSON serialisieren (für string / []byte / typed map / struct)
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch fv.Kind() {
|
||||
case reflect.Interface:
|
||||
// interface{} / any -> direkt map setzen
|
||||
fv.Set(reflect.ValueOf(m))
|
||||
return
|
||||
|
||||
case reflect.String:
|
||||
fv.SetString(string(b))
|
||||
return
|
||||
|
||||
case reflect.Slice:
|
||||
// []byte / json.RawMessage
|
||||
if fv.Type().Elem().Kind() == reflect.Uint8 {
|
||||
fv.SetBytes(b)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: in den echten Feldtyp unmarshaln
|
||||
ptr := reflect.New(fv.Type())
|
||||
if err := json.Unmarshal(b, ptr.Interface()); err == nil {
|
||||
fv.Set(ptr.Elem())
|
||||
}
|
||||
}
|
||||
|
||||
func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
|
||||
if j == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// ID aus Output ableiten (canonical: ohne HOT, ohne Ext)
|
||||
outPath := strings.TrimSpace(j.Output)
|
||||
if outPath == "" {
|
||||
return
|
||||
}
|
||||
base := filepath.Base(outPath)
|
||||
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||
id = strings.TrimSpace(id)
|
||||
|
||||
ps := previewSpriteTruthForID(id)
|
||||
|
||||
// per Reflection auf Feld "Meta" zugreifen (robust gegen Meta-Typ)
|
||||
rv := reflect.ValueOf(j)
|
||||
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||
return
|
||||
}
|
||||
sv := rv.Elem()
|
||||
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
fv := sv.FieldByName("Meta")
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
// Falls RecordJob kein Meta-Feld hat -> nichts zu tun
|
||||
return
|
||||
}
|
||||
|
||||
var raw any
|
||||
switch fv.Kind() {
|
||||
case reflect.Interface:
|
||||
if fv.IsNil() {
|
||||
raw = nil
|
||||
} else {
|
||||
raw = fv.Interface()
|
||||
}
|
||||
default:
|
||||
raw = fv.Interface()
|
||||
}
|
||||
|
||||
meta := metaMapFromAny(raw)
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
|
||||
// ✅ Legacy/Fallback Felder killen (falls vorhanden)
|
||||
delete(meta, "previewScrubberPath")
|
||||
delete(meta, "previewScrubberCount")
|
||||
|
||||
// ✅ previewSprite hart mit echter Dateiwahrheit überschreiben
|
||||
psMap := map[string]any{
|
||||
"exists": ps.Exists,
|
||||
}
|
||||
|
||||
if ps.Exists {
|
||||
psMap["path"] = ps.Path
|
||||
|
||||
if ps.Count > 0 {
|
||||
psMap["count"] = ps.Count
|
||||
}
|
||||
if ps.Cols > 0 {
|
||||
psMap["cols"] = ps.Cols
|
||||
}
|
||||
if ps.Rows > 0 {
|
||||
psMap["rows"] = ps.Rows
|
||||
}
|
||||
if ps.StepSeconds > 0 {
|
||||
psMap["stepSeconds"] = ps.StepSeconds
|
||||
}
|
||||
}
|
||||
|
||||
meta["previewSprite"] = psMap
|
||||
|
||||
setStructFieldJSONMap(fv, meta)
|
||||
}
|
||||
|
||||
func recordList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
|
||||
@ -551,6 +939,8 @@ func recordStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
applyPreviewSpriteTruthToRecordJobMeta(job)
|
||||
|
||||
json.NewEncoder(w).Encode(job)
|
||||
}
|
||||
|
||||
@ -949,22 +1339,14 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
||||
// ✅ best-effort meta.json erzeugen
|
||||
ensureMetaJSONForPlayback(r.Context(), outPath)
|
||||
|
||||
// Response-Shape: bewusst "fertig" fürs Frontend
|
||||
type doneMetaFileResp struct {
|
||||
File string `json:"file"`
|
||||
MetaExists bool `json:"metaExists"`
|
||||
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
FPS float64 `json:"fps,omitempty"`
|
||||
SourceURL string `json:"sourceUrl,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
resp := doneMetaFileResp{File: filepath.Base(outPath)}
|
||||
|
||||
// meta lesen (wenn vorhanden)
|
||||
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
|
||||
|
||||
// ✅ Preview-Sprite-Truth immer setzen (explizit true/false)
|
||||
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
|
||||
|
||||
if strings.TrimSpace(id) != "" {
|
||||
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
||||
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
|
||||
@ -1385,6 +1767,9 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Preview-Sprite-Truth im LIST-Payload erzwingen (wichtig für Cards/Gallery)
|
||||
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
||||
|
||||
out = append(out, &c)
|
||||
}
|
||||
|
||||
|
||||
1
backend/web/dist/assets/index-B-X4TsOo.css
vendored
Normal file
1
backend/web/dist/assets/index-B-X4TsOo.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-BZTD4GKM.css
vendored
1
backend/web/dist/assets/index-BZTD4GKM.css
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
1
backend/web/dist/assets/index-CIN0UidG.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>App</title>
|
||||
<script type="module" crossorigin src="/assets/index-BjA9ZqZd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZTD4GKM.css">
|
||||
<script type="module" crossorigin src="/assets/index-DNoPI-qJ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B-X4TsOo.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@ -40,7 +40,7 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
type RecorderSettings = {
|
||||
type RecorderSettingsState = {
|
||||
recordDir: string
|
||||
doneDir: string
|
||||
ffmpegPath?: string
|
||||
@ -56,7 +56,7 @@ type RecorderSettings = {
|
||||
lowDiskPauseBelowGB?: number
|
||||
}
|
||||
|
||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettings = {
|
||||
const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||
recordDir: 'records',
|
||||
doneDir: 'records/done',
|
||||
ffmpegPath: '',
|
||||
@ -580,7 +580,7 @@ export default function App() {
|
||||
|
||||
const refreshModelsByKey = useCallback(async () => {
|
||||
try {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
|
||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
|
||||
setModelsByKey(buildModelsByKey(Array.isArray(list) ? list : []))
|
||||
setLastHeaderUpdateAtMs(Date.now())
|
||||
} catch {
|
||||
@ -656,7 +656,7 @@ export default function App() {
|
||||
const detail = e?.detail ?? {}
|
||||
const updated = detail?.model
|
||||
|
||||
// ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models/list)
|
||||
// ✅ 1) Update-Event mit Model: direkt in State übernehmen (KEIN /api/models)
|
||||
if (updated && typeof updated === 'object') {
|
||||
const k = String(updated.modelKey ?? '').toLowerCase().trim()
|
||||
if (k) setModelsByKey((prev) => ({ ...prev, [k]: updated }))
|
||||
@ -706,11 +706,12 @@ export default function App() {
|
||||
const [selectedTab, setSelectedTab] = useState('running')
|
||||
const [playerJob, setPlayerJob] = useState<RecordJob | null>(null)
|
||||
const [playerExpanded, setPlayerExpanded] = useState(false)
|
||||
const [playerStartAtSec, setPlayerStartAtSec] = useState<number | null>(null)
|
||||
|
||||
const [assetNonce, setAssetNonce] = useState(0)
|
||||
const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), [])
|
||||
|
||||
const [recSettings, setRecSettings] = useState<RecorderSettings>(DEFAULT_RECORDER_SETTINGS)
|
||||
const [recSettings, setRecSettings] = useState<RecorderSettingsState>(DEFAULT_RECORDER_SETTINGS)
|
||||
const recSettingsRef = useRef(recSettings)
|
||||
useEffect(() => {
|
||||
recSettingsRef.current = recSettings
|
||||
@ -1059,7 +1060,7 @@ export default function App() {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const s = await apiJSON<RecorderSettings>('/api/settings', { cache: 'no-store' })
|
||||
const s = await apiJSON<RecorderSettingsState>('/api/settings', { cache: 'no-store' })
|
||||
if (!cancelled && s) setRecSettings({ ...DEFAULT_RECORDER_SETTINGS, ...s })
|
||||
} catch {
|
||||
// ignore
|
||||
@ -1110,11 +1111,16 @@ export default function App() {
|
||||
|
||||
const initialCookies = useMemo(() => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies])
|
||||
|
||||
const openPlayer = useCallback((job: RecordJob) => {
|
||||
const openPlayer = useCallback((job: RecordJob, startAtSec?: number) => {
|
||||
modelsCacheRef.current = null
|
||||
setPlayerModel(null)
|
||||
setPlayerJob(job)
|
||||
setPlayerExpanded(false)
|
||||
setPlayerStartAtSec(
|
||||
typeof startAtSec === 'number' && Number.isFinite(startAtSec) && startAtSec >= 0
|
||||
? startAtSec
|
||||
: null
|
||||
)
|
||||
}, [])
|
||||
|
||||
const runningJobs = jobs.filter((j) => {
|
||||
@ -1670,7 +1676,7 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
},
|
||||
[selectedTab, refreshDoneNow, notify]
|
||||
[notify]
|
||||
)
|
||||
|
||||
const handleToggleHot = useCallback(
|
||||
@ -2240,7 +2246,7 @@ export default function App() {
|
||||
return stateHit
|
||||
}
|
||||
|
||||
// ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models/list)
|
||||
// ✅ 1) Wenn ensure gewünscht: DIREKT ensure (kein /api/models)
|
||||
if (wantEnsure) {
|
||||
let host: string | undefined
|
||||
|
||||
@ -2270,7 +2276,7 @@ export default function App() {
|
||||
if (seeded.length) {
|
||||
modelsCacheRef.current = { ts: now, list: seeded }
|
||||
} else {
|
||||
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
|
||||
const list = await apiJSON<StoredModel[]>('/api/models', { cache: 'no-store' as any })
|
||||
modelsCacheRef.current = { ts: now, list: Array.isArray(list) ? list : [] }
|
||||
}
|
||||
}
|
||||
@ -2380,7 +2386,7 @@ export default function App() {
|
||||
window.removeEventListener('hover', kick)
|
||||
document.removeEventListener('visibilitychange', kick)
|
||||
}
|
||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||
}, [autoAddEnabled, autoStartEnabled, enqueueStart])
|
||||
|
||||
useEffect(() => {
|
||||
const stop = startChaturbateOnlinePolling({
|
||||
@ -2905,7 +2911,11 @@ export default function App() {
|
||||
modelsByKey={modelsByKey}
|
||||
expanded={playerExpanded}
|
||||
onToggleExpand={() => setPlayerExpanded((s) => !s)}
|
||||
onClose={() => setPlayerJob(null)}
|
||||
onClose={() => {
|
||||
setPlayerJob(null)
|
||||
setPlayerStartAtSec(null)
|
||||
}}
|
||||
startAtSec={playerStartAtSec ?? undefined}
|
||||
isHot={baseName(playerJob.output || '').startsWith('HOT ')}
|
||||
isFavorite={Boolean(playerModel?.favorite)}
|
||||
isLiked={playerModel?.liked === true}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
type Size = 'sm' | 'md'
|
||||
type Size = 'sm' | 'md' | 'lg'
|
||||
|
||||
export type ButtonGroupItem = {
|
||||
id: string
|
||||
@ -29,6 +29,7 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
||||
const sizeMap: Record<Size, { btn: string; icon: string; iconOnly: string }> = {
|
||||
sm: { btn: 'px-2.5 py-1.5 text-sm', icon: 'size-5', iconOnly: 'h-9 w-9' },
|
||||
md: { btn: 'px-3 py-2 text-sm', icon: 'size-5', iconOnly: 'h-10 w-10' },
|
||||
lg: { btn: 'px-3.5 py-2.5 text-sm', icon: 'size-5', iconOnly: 'h-11 w-11' },
|
||||
}
|
||||
|
||||
export default function ButtonGroup({
|
||||
|
||||
@ -216,7 +216,7 @@ export default function CategoriesTab() {
|
||||
try {
|
||||
// parallel laden
|
||||
const [models, doneResp] = await Promise.all([
|
||||
apiJSON<StoredModel[]>('/api/models/list', {
|
||||
apiJSON<StoredModel[]>('/api/models', {
|
||||
cache: 'no-store' as any,
|
||||
signal: ac.signal as any,
|
||||
}),
|
||||
|
||||
@ -47,7 +47,7 @@ type Props = {
|
||||
blurPreviews?: boolean
|
||||
teaserPlayback?: TeaserPlaybackMode
|
||||
teaserAudio?: boolean
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
|
||||
onDeleteJob?: (
|
||||
job: RecordJob
|
||||
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
|
||||
@ -748,11 +748,115 @@ export default function FinishedDownloads({
|
||||
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
||||
}, [])
|
||||
|
||||
const startInlineAt = useCallback((key: string, seconds: number, domId: string) => {
|
||||
const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||||
|
||||
// Inline-Preview aktivieren / remount erzwingen
|
||||
setInlinePlay((prev) => (prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 }))
|
||||
|
||||
// Nach dem Rendern das Video suchen, seeken und autoplay versuchen
|
||||
const trySeekAndPlay = (retriesLeft: number) => {
|
||||
const host = document.getElementById(domId)
|
||||
const v = host?.querySelector('video') as HTMLVideoElement | null
|
||||
|
||||
if (!v) {
|
||||
if (retriesLeft > 0) {
|
||||
requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
applyInlineVideoPolicy(v, { muted: previewMuted })
|
||||
|
||||
const applySeek = () => {
|
||||
try {
|
||||
const dur = Number(v.duration)
|
||||
const maxSeek =
|
||||
Number.isFinite(dur) && dur > 0
|
||||
? Math.max(0, dur - 0.05)
|
||||
: safeSeconds
|
||||
|
||||
v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const p = v.play?.()
|
||||
if (p && typeof (p as any).catch === 'function') {
|
||||
;(p as Promise<void>).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn Metadaten schon da sind -> direkt seeken
|
||||
if (v.readyState >= 1) {
|
||||
applySeek()
|
||||
return
|
||||
}
|
||||
|
||||
// Sonst warten bis metadata da sind
|
||||
const onLoadedMetadata = () => {
|
||||
v.removeEventListener('loadedmetadata', onLoadedMetadata)
|
||||
applySeek()
|
||||
}
|
||||
|
||||
v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true })
|
||||
|
||||
// zusätzlich sofort play versuchen (hilft manchmal)
|
||||
const p = v.play?.()
|
||||
if (p && typeof (p as any).catch === 'function') {
|
||||
;(p as Promise<void>).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => trySeekAndPlay(8))
|
||||
}, [previewMuted])
|
||||
|
||||
const openPlayer = useCallback((job: RecordJob) => {
|
||||
setInlinePlay(null)
|
||||
onOpenPlayer(job)
|
||||
}, [onOpenPlayer])
|
||||
|
||||
const openPlayerAt = useCallback((job: RecordJob, seconds: number) => {
|
||||
const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0
|
||||
setInlinePlay(null)
|
||||
onOpenPlayer(job, s)
|
||||
}, [onOpenPlayer])
|
||||
|
||||
const handleScrubberClickIndex = useCallback(
|
||||
(job: RecordJob, segmentIndex: number, segmentCount: number) => {
|
||||
const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0
|
||||
const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0
|
||||
|
||||
if (count <= 0) {
|
||||
// Fallback: Player normal öffnen
|
||||
openPlayer(job)
|
||||
return
|
||||
}
|
||||
|
||||
// Dauer bevorzugt aus Preview-Metadaten, sonst aus Job
|
||||
const k = keyFor(job)
|
||||
const durationSec =
|
||||
durations[k] ??
|
||||
((job as any)?.durationSeconds as number | undefined) ??
|
||||
0
|
||||
|
||||
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||||
// Wenn keine Dauer bekannt ist: trotzdem öffnen (ohne Timestamp)
|
||||
openPlayer(job)
|
||||
return
|
||||
}
|
||||
|
||||
// Segment-Index -> Startsekunde
|
||||
// Beispiel: 10 Segmente, Klick auf Index 0..9
|
||||
const clampedIdx = Math.max(0, Math.min(idx, count - 1))
|
||||
const secPerSegment = durationSec / count
|
||||
const startAtSec = clampedIdx * secPerSegment
|
||||
|
||||
openPlayerAt(job, startAtSec)
|
||||
},
|
||||
[durations, keyFor, openPlayer, openPlayerAt]
|
||||
)
|
||||
|
||||
const markDeleting = useCallback((key: string, value: boolean) => {
|
||||
setDeletingKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
@ -1944,6 +2048,9 @@ export default function FinishedDownloads({
|
||||
lower={lower}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
openPlayer={openPlayer}
|
||||
onOpenPlayerAt={openPlayerAt}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
startInlineAt={startInlineAt}
|
||||
startInline={startInline}
|
||||
tryAutoplayInline={tryAutoplayInline}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
@ -1995,6 +2102,7 @@ export default function FinishedDownloads({
|
||||
activeTagSet={activeTagSet}
|
||||
onToggleTagFilter={toggleTagFilter}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
onSortModeChange={onSortModeChange}
|
||||
page={page}
|
||||
onPageChange={onPageChange}
|
||||
@ -2030,6 +2138,7 @@ export default function FinishedDownloads({
|
||||
deletedKeys={deletedKeys}
|
||||
registerTeaserHost={registerTeaserHost}
|
||||
onOpenPlayer={onOpenPlayer}
|
||||
handleScrubberClickIndex={handleScrubberClickIndex}
|
||||
deleteVideo={deleteVideo}
|
||||
keepVideo={keepVideo}
|
||||
onToggleHot={toggleHotVideo}
|
||||
|
||||
@ -40,6 +40,8 @@ type Props = {
|
||||
|
||||
assetNonce?: number
|
||||
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
|
||||
// helpers
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
@ -53,6 +55,8 @@ type Props = {
|
||||
onHoverPreviewKeyChange?: (key: string | null) => void
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
openPlayer: (job: RecordJob) => void
|
||||
onOpenPlayerAt?: (job: RecordJob, seconds: number) => void
|
||||
startInlineAt?: (key: string, seconds: number, domId: string) => void
|
||||
startInline: (key: string) => void
|
||||
tryAutoplayInline: (domId: string) => boolean
|
||||
registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void
|
||||
@ -161,7 +165,7 @@ export default function FinishedDownloadsCardsView({
|
||||
teaserAudio,
|
||||
hoverTeaserKey,
|
||||
blurPreviews,
|
||||
durations, // ✅ fehlte
|
||||
durations,
|
||||
teaserKey,
|
||||
inlinePlay,
|
||||
deletingKeys,
|
||||
@ -170,6 +174,7 @@ export default function FinishedDownloadsCardsView({
|
||||
|
||||
swipeRefs,
|
||||
assetNonce,
|
||||
handleScrubberClickIndex,
|
||||
|
||||
keyFor,
|
||||
baseName,
|
||||
@ -182,6 +187,7 @@ export default function FinishedDownloadsCardsView({
|
||||
onHoverPreviewKeyChange,
|
||||
onOpenPlayer,
|
||||
openPlayer,
|
||||
startInlineAt,
|
||||
startInline,
|
||||
tryAutoplayInline,
|
||||
registerTeaserHost,
|
||||
@ -276,6 +282,7 @@ export default function FinishedDownloadsCardsView({
|
||||
)
|
||||
|
||||
const [scrubActiveByKey, setScrubActiveByKey] = React.useState<Record<string, number | undefined>>({})
|
||||
const [scrubHoveringByKey, setScrubHoveringByKey] = React.useState<Record<string, boolean | undefined>>({})
|
||||
|
||||
const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => {
|
||||
setScrubActiveByKey((prev) => {
|
||||
@ -295,6 +302,19 @@ export default function FinishedDownloadsCardsView({
|
||||
setScrubActiveIndex(key, undefined)
|
||||
}, [setScrubActiveIndex])
|
||||
|
||||
const setScrubHovering = React.useCallback((key: string, hovering: boolean | undefined) => {
|
||||
setScrubHoveringByKey((prev) => {
|
||||
if (hovering === undefined) {
|
||||
if (!(key in prev)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
if (prev[key] === hovering) return prev
|
||||
return { ...prev, [key]: hovering }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const renderCardItem = (
|
||||
j: RecordJob,
|
||||
opts?: {
|
||||
@ -344,6 +364,7 @@ export default function FinishedDownloadsCardsView({
|
||||
const meta = parseMeta(j)
|
||||
const spriteInfo = previewScrubberInfoOf(j)
|
||||
const scrubActiveIndex = scrubActiveByKey[k]
|
||||
const scrubHovering = scrubHoveringByKey[k] === true
|
||||
|
||||
// ✅ Sprite-Quelle wie in GalleryView (1 Request, danach nur CSS background-position)
|
||||
const spritePathRaw = firstNonEmptyString(
|
||||
@ -437,6 +458,11 @@ export default function FinishedDownloadsCardsView({
|
||||
const scrubberCount = hasScrubberUi ? spriteCount : 0
|
||||
const scrubberStepSeconds = hasScrubberUi ? spriteStepSeconds : 0
|
||||
|
||||
const scrubProgressRatio =
|
||||
typeof scrubActiveIndex === 'number' && scrubberCount > 1
|
||||
? clamp(scrubActiveIndex / (scrubberCount - 1), 0, 1)
|
||||
: undefined
|
||||
|
||||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||||
hasSpriteScrubber && typeof scrubActiveIndex === 'number'
|
||||
? (() => {
|
||||
@ -456,9 +482,6 @@ export default function FinishedDownloadsCardsView({
|
||||
})()
|
||||
: undefined
|
||||
|
||||
const showScrubberSpriteInThumb = Boolean(spriteFrameStyle)
|
||||
const hideTeaserUnderOverlay = showScrubberSpriteInThumb
|
||||
|
||||
const isHot = isHotName(fileRaw)
|
||||
const isFav = Boolean(flags?.favorite)
|
||||
const isLiked = flags?.liked === true
|
||||
@ -566,7 +589,7 @@ export default function FinishedDownloadsCardsView({
|
||||
onDuration={handleDuration}
|
||||
showPopover={false}
|
||||
blur={inlineActive ? false : Boolean(blurPreviews)}
|
||||
animated={hideTeaserUnderOverlay ? false : allowTeaserAnimation}
|
||||
animated={allowTeaserAnimation}
|
||||
animatedMode="teaser"
|
||||
animatedTrigger="always"
|
||||
clipSeconds={1}
|
||||
@ -581,6 +604,8 @@ export default function FinishedDownloadsCardsView({
|
||||
alwaysLoadStill={forceLoadStill}
|
||||
teaserPreloadEnabled={opts?.mobileStackTopOnlyVideo ? true : !isSmall}
|
||||
teaserPreloadRootMargin={isSmall ? '900px 0px' : '700px 0px'}
|
||||
scrubProgressRatio={scrubProgressRatio}
|
||||
preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'}
|
||||
/>
|
||||
|
||||
{/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */}
|
||||
@ -596,24 +621,72 @@ export default function FinishedDownloadsCardsView({
|
||||
) : null}
|
||||
|
||||
{/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */}
|
||||
{hasSpriteScrubber && spriteFrameStyle ? (
|
||||
<div className="absolute inset-0 z-[5]" aria-hidden="true">
|
||||
{hasSpriteScrubber && spriteFrameStyle && !inlineActive ? (
|
||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||
<div className="h-full w-full" style={spriteFrameStyle} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ✅ stashapp-artiger Hover-Scrubber (wie GalleryView) */}
|
||||
{!opts?.isDecorative && scrubberCount > 1 ? (
|
||||
{!opts?.isDecorative && !inlineActive && scrubberCount > 1 ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onMouseEnter={() => setScrubHovering(k, true)}
|
||||
onMouseLeave={() => {
|
||||
setScrubHovering(k, false)
|
||||
// optional: Index sofort loslassen, dann springt Bar direkt zurück auf Teaser
|
||||
setScrubActiveIndex(k, undefined)
|
||||
}}
|
||||
>
|
||||
<PreviewScrubber
|
||||
className="pointer-events-auto px-1"
|
||||
imageCount={scrubberCount}
|
||||
activeIndex={scrubActiveIndex}
|
||||
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
||||
onIndexClick={(index) => {
|
||||
// wie Preview-Klick: inline starten
|
||||
if (isSmall || opts?.disableInline) {
|
||||
// Mobile/Decorative/Fallback: bestehendes Verhalten
|
||||
handleScrubberClickIndex(j, index, scrubberCount)
|
||||
return
|
||||
}
|
||||
|
||||
// Zielsekunde aus Scrubber ableiten
|
||||
const seconds =
|
||||
scrubberStepSeconds > 0
|
||||
? index * scrubberStepSeconds
|
||||
: 0
|
||||
|
||||
// 1) bevorzugt: direkt inline an Position starten (falls Parent das unterstützt)
|
||||
if (startInlineAt) {
|
||||
startInlineAt(k, seconds, inlineDomId)
|
||||
|
||||
// wie bei Tap im Mobile-Stack: Autoplay nochmal anschubsen
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(inlineDomId)) {
|
||||
requestAnimationFrame(() => {
|
||||
tryAutoplayInline(inlineDomId)
|
||||
})
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2) Fallback: inline normal starten (ohne exakten Seek)
|
||||
startInline(k)
|
||||
requestAnimationFrame(() => {
|
||||
if (!tryAutoplayInline(inlineDomId)) {
|
||||
requestAnimationFrame(() => {
|
||||
tryAutoplayInline(inlineDomId)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 3) Optionaler Fallback auf bestehenden Handler (wenn du dort OpenPlayerAt machst)
|
||||
// handleScrubberClickIndex(j, index, scrubberCount)
|
||||
}}
|
||||
stepSeconds={scrubberStepSeconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -42,6 +42,8 @@ type Props = {
|
||||
|
||||
handleDuration: (job: RecordJob, seconds: number) => void
|
||||
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
|
||||
keyFor: (j: RecordJob) => string
|
||||
baseName: (p: string) => string
|
||||
modelNameFromOutput: (output?: string) => string
|
||||
@ -148,6 +150,7 @@ export default function FinishedDownloadsGalleryView({
|
||||
teaserKey,
|
||||
|
||||
handleDuration,
|
||||
handleScrubberClickIndex,
|
||||
keyFor,
|
||||
baseName,
|
||||
modelNameFromOutput,
|
||||
@ -394,6 +397,11 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
const activeScrubIndex = scrubIndexByKey[k]
|
||||
|
||||
const scrubProgressRatio =
|
||||
typeof activeScrubIndex === 'number' && scrubberCount > 1
|
||||
? clamp(activeScrubIndex / (scrubberCount - 1), 0, 1)
|
||||
: undefined
|
||||
|
||||
// Sprite-Overlay-Frame (kein Request pro Move)
|
||||
const spriteFrameStyle: React.CSSProperties | undefined =
|
||||
hasSpriteScrubber && typeof activeScrubIndex === 'number'
|
||||
@ -481,6 +489,8 @@ export default function FinishedDownloadsGalleryView({
|
||||
thumbSamples={18}
|
||||
muted={previewMuted}
|
||||
popoverMuted={previewMuted}
|
||||
scrubProgressRatio={scrubProgressRatio}
|
||||
preferScrubProgress={typeof activeScrubIndex === 'number'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -498,11 +508,10 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
{/* ✅ Scrubber-Frame Overlay (Sprite-first = stashapp-like, kein Request pro Move) */}
|
||||
{showScrubberSpriteInThumb && spriteFrameStyle ? (
|
||||
<div className="absolute inset-0 z-[5]">
|
||||
<div className="absolute inset-x-0 top-0 bottom-[6px] z-[5]" aria-hidden="true">
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={spriteFrameStyle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -526,12 +535,23 @@ export default function FinishedDownloadsGalleryView({
|
||||
|
||||
{/* ✅ stashapp-artiger Hover-Scrubber (UI-only) */}
|
||||
{hasScrubber ? (
|
||||
<div className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150">
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-30 pointer-events-none opacity-100 transition-opacity duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PreviewScrubber
|
||||
className="pointer-events-auto px-1"
|
||||
imageCount={scrubberCount}
|
||||
activeIndex={activeScrubIndex}
|
||||
onActiveIndexChange={(idx) => setScrubIndexForKey(k, idx)}
|
||||
onIndexClick={(index) => {
|
||||
// optional: UI-Zustand direkt sichtbar halten
|
||||
setScrubIndexForKey(k, index)
|
||||
|
||||
// bestehender Handler (Parent entscheidet: openPlayerAt / modal / etc.)
|
||||
handleScrubberClickIndex(j, index, scrubberCount)
|
||||
}}
|
||||
stepSeconds={scrubberStepSeconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,6 @@ import type { RecordJob } from '../../types'
|
||||
import FinishedVideoPreview from './FinishedVideoPreview'
|
||||
import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import PreviewScrubber from './PreviewScrubber'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import { formatResolution } from './formatters'
|
||||
|
||||
@ -59,6 +58,7 @@ type Props = {
|
||||
modelsByKey: Record<string, { favorite?: boolean; liked?: boolean | null; watching?: boolean | null; tags?: string }>
|
||||
activeTagSet: Set<string>
|
||||
onToggleTagFilter: (tag: string) => void
|
||||
handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void
|
||||
|
||||
// actions
|
||||
onOpenPlayer: (job: RecordJob) => void
|
||||
@ -274,25 +274,6 @@ export default function FinishedDownloadsTableView({
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Scrubber (Mobile sichtbar, Desktop nur Hover) */}
|
||||
{spriteInfo ? (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 z-[20] px-0.5"
|
||||
onPointerUp={() => setScrubActiveIndex(k, undefined)}
|
||||
onPointerCancel={() => setScrubActiveIndex(k, undefined)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PreviewScrubber
|
||||
imageCount={spriteInfo.count}
|
||||
activeIndex={scrubActiveIndex}
|
||||
onActiveIndexChange={(idx) => setScrubActiveIndex(k, idx)}
|
||||
stepSeconds={spriteInfo.stepSeconds}
|
||||
className="opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:group-hover:opacity-100 md:group-focus-within:opacity-100 md:group-hover:pointer-events-auto md:group-focus-within:pointer-events-auto"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@ -76,6 +76,9 @@ export type FinishedVideoPreviewProps = {
|
||||
|
||||
/** Vorlade-Zone für Teaser (IntersectionObserver rootMargin) */
|
||||
teaserPreloadRootMargin?: string
|
||||
|
||||
scrubProgressRatio?: number
|
||||
preferScrubProgress?: boolean
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
@ -115,6 +118,8 @@ export default function FinishedVideoPreview({
|
||||
alwaysLoadStill = false,
|
||||
teaserPreloadEnabled = false,
|
||||
teaserPreloadRootMargin = '700px 0px',
|
||||
scrubProgressRatio,
|
||||
preferScrubProgress = false,
|
||||
}: FinishedVideoPreviewProps) {
|
||||
const file = getFileName(job.output || '')
|
||||
const blurCls = blur ? 'blur-md' : ''
|
||||
@ -135,7 +140,15 @@ export default function FinishedVideoPreview({
|
||||
|
||||
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
|
||||
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
|
||||
const metaForPreview = meta ?? fetchedMeta
|
||||
|
||||
// ✅ merge statt "meta ?? fetchedMeta"
|
||||
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
|
||||
const metaForPreview = useMemo(() => {
|
||||
if (!meta && !fetchedMeta) return null
|
||||
if (!meta) return fetchedMeta
|
||||
if (!fetchedMeta) return meta
|
||||
return { ...meta, ...fetchedMeta }
|
||||
}, [meta, fetchedMeta])
|
||||
|
||||
const [progressMountTick, setProgressMountTick] = useState(0)
|
||||
|
||||
@ -352,7 +365,7 @@ export default function FinishedVideoPreview({
|
||||
// Ratio basiert auf vvDur (z.B. 2/18) — unabhängig von totalSeconds.
|
||||
const readProgressStepped = (
|
||||
vv: HTMLVideoElement | null,
|
||||
totalSeconds: number | undefined, // bleibt drin (nur für clamp/teaser-end)
|
||||
totalSeconds: number | undefined,
|
||||
stepSec = clipSeconds,
|
||||
forceTeaserMap = false
|
||||
): { ratio: number; globalSec: number; vvDur: number } => {
|
||||
@ -365,23 +378,21 @@ export default function FinishedVideoPreview({
|
||||
const tPreview = Number(vv.currentTime)
|
||||
if (!Number.isFinite(tPreview) || tPreview < 0) return { ratio: 0, globalSec: 0, vvDur }
|
||||
|
||||
let globalSec = 0
|
||||
const m = previewClipMap
|
||||
let globalSec = 0
|
||||
|
||||
if (forceTeaserMap && Array.isArray(m) && m.length > 0) {
|
||||
const last = m[m.length - 1]
|
||||
|
||||
// Ende -> global = totalSeconds (falls bekannt), sonst Segment-Ende
|
||||
if (tPreview >= last.cumEnd) {
|
||||
globalSec =
|
||||
typeof totalSeconds === 'number' && Number.isFinite(totalSeconds) && totalSeconds > 0
|
||||
? totalSeconds
|
||||
: last.start + last.dur
|
||||
} else {
|
||||
// Segment finden
|
||||
let lo = 0
|
||||
let hi = m.length - 1
|
||||
let seg = m[0]
|
||||
let segIdx = 0
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
@ -389,24 +400,31 @@ export default function FinishedVideoPreview({
|
||||
if (tPreview < c.cumStart) hi = mid - 1
|
||||
else if (tPreview >= c.cumEnd) lo = mid + 1
|
||||
else {
|
||||
seg = c
|
||||
segIdx = mid
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const within = Math.max(0, tPreview - seg.cumStart)
|
||||
const snapped = Math.floor(within / stepSec) * stepSec
|
||||
globalSec = seg.start + Math.min(snapped, seg.dur)
|
||||
const seg = m[segIdx]
|
||||
globalSec = seg.start
|
||||
|
||||
// ✅ Sichtbarer Teaser-Progress exakt am Segmentindex ausrichten
|
||||
// springt auf Beginn des aktuell aktiven Preview-Segments (kein floor-lag über currentTime)
|
||||
const ratio = m.length > 0 ? clamp01(segIdx / m.length) : 0
|
||||
|
||||
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
|
||||
}
|
||||
} else {
|
||||
// inline/clips: global = currentTime (gesnappt)
|
||||
globalSec = Math.floor(tPreview / stepSec) * stepSec
|
||||
}
|
||||
|
||||
// ✅ Balken-Ratio basiert auf vvDur
|
||||
const g = Math.max(0, Math.min(globalSec, vvDur))
|
||||
const ratio = clamp01(g / vvDur)
|
||||
return { ratio, globalSec: g, vvDur }
|
||||
// inline/clips fallback
|
||||
if (Number.isFinite(stepSec) && stepSec > 0) {
|
||||
globalSec = Math.floor(tPreview / stepSec) * stepSec
|
||||
} else {
|
||||
globalSec = tPreview
|
||||
}
|
||||
|
||||
const ratio = clamp01(Math.min(globalSec, vvDur) / vvDur)
|
||||
return { ratio, globalSec: Math.max(0, globalSec), vvDur }
|
||||
}
|
||||
|
||||
const hardStop = (v: HTMLVideoElement | null) => {
|
||||
@ -561,7 +579,8 @@ export default function FinishedVideoPreview({
|
||||
(animatedTrigger === 'always' || hovered) &&
|
||||
((animatedMode === 'teaser' && teaserOk && Boolean(teaserSrc)) || (animatedMode === 'clips' && hasDuration))
|
||||
|
||||
const progressTotalSeconds = hasDuration ? effectiveDurationSec : undefined
|
||||
const progressTotalSeconds =
|
||||
hasDuration && typeof effectiveDurationSec === 'number' ? effectiveDurationSec : undefined
|
||||
|
||||
// ✅ Still-Bild: optional immer laden (entkoppelt vom inView-Gating)
|
||||
const shouldLoadStill = alwaysLoadStill || inView || everInView || (wantsHover && hovered)
|
||||
@ -686,10 +705,7 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const showProgressBar =
|
||||
Boolean(progressVideoRef) &&
|
||||
inView &&
|
||||
typeof progressTotalSeconds === 'number' &&
|
||||
Number.isFinite(progressTotalSeconds) &&
|
||||
progressTotalSeconds > 0
|
||||
inView
|
||||
|
||||
const progressKind: ProgressKind =
|
||||
showingInlineVideo ? 'inline' : teaserActive && animatedMode === 'teaser' ? 'teaser' : 'clips'
|
||||
@ -705,9 +721,28 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const frameRatio = showFrameProgress ? clamp01(thumbTimeSec! / effectiveDurationSec!) : 0
|
||||
|
||||
// finaler Balken: Video-Progress hat Priorität, sonst Frames-Progress
|
||||
const progressRatio = showProgressBar ? playRatio : showFrameProgress ? frameRatio : 0
|
||||
const showAnyProgress = showProgressBar || showFrameProgress
|
||||
const hasScrubProgress =
|
||||
!showingInlineVideo &&
|
||||
preferScrubProgress &&
|
||||
typeof scrubProgressRatio === 'number' &&
|
||||
Number.isFinite(scrubProgressRatio)
|
||||
|
||||
// finaler Balken:
|
||||
// 1) externer Scrub-Progress (wenn aktiv)
|
||||
// 2) Video-Progress
|
||||
// 3) Frames-Progress
|
||||
const progressRatio = hasScrubProgress
|
||||
? clamp01(scrubProgressRatio!)
|
||||
: showProgressBar
|
||||
? playRatio
|
||||
: showFrameProgress
|
||||
? frameRatio
|
||||
: 0
|
||||
|
||||
const hasBasePreviewProgress = showProgressBar || showFrameProgress
|
||||
|
||||
const showAnyProgress =
|
||||
!showingInlineVideo && (hasScrubProgress || hasBasePreviewProgress)
|
||||
|
||||
const clipOverlay = useMemo(() => {
|
||||
if (!hasDuration) return null
|
||||
@ -818,7 +853,7 @@ export default function FinishedVideoPreview({
|
||||
sync()
|
||||
|
||||
// ✅ Sekundentakt (robust, unabhängig von raf/play-events)
|
||||
timer = window.setInterval(sync, 1000)
|
||||
timer = window.setInterval(sync, 100)
|
||||
|
||||
// optional: bei metadata/timeupdate sofort einmal syncen
|
||||
const onLoaded = () => sync()
|
||||
@ -897,11 +932,18 @@ export default function FinishedVideoPreview({
|
||||
!metaLoaded &&
|
||||
!showingInlineVideo &&
|
||||
((onDuration && !hasDuration) || (onResolution && !hasResolution))
|
||||
|
||||
const showTeaserSegments =
|
||||
Boolean(clipOverlay) &&
|
||||
(
|
||||
progressKind === 'teaser' ||
|
||||
(!showingInlineVideo && hasScrubProgress && animatedMode === 'teaser')
|
||||
)
|
||||
|
||||
const previewNode = (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={['group rounded bg-gray-100 dark:bg-white/5 overflow-hidden relative isolate', sizeClass, className ?? ''].join(' ')}
|
||||
className={['group bg-gray-100 dark:bg-white/5 overflow-hidden relative', sizeClass, className ?? ''].join(' ')}
|
||||
onMouseEnter={wantsHover ? () => setHovered(true) : undefined}
|
||||
onMouseLeave={wantsHover ? () => setHovered(false) : undefined}
|
||||
onFocus={wantsHover ? () => setHovered(true) : undefined}
|
||||
@ -1025,9 +1067,9 @@ export default function FinishedVideoPreview({
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={[
|
||||
'absolute left-0 right-0 bottom-0 z-[2] pointer-events-none',
|
||||
'absolute left-0 right-0 bottom-0 z-40 pointer-events-none',
|
||||
// etwas höher + bei hover deutlich
|
||||
'h-0.5 group-hover:h-1.5',
|
||||
'h-0.5 group-hover:h-1',
|
||||
'transition-[height] duration-150 ease-out',
|
||||
// Track: heller + border/inset für Kontrast
|
||||
'rounded-none group-hover:rounded-full',
|
||||
@ -1037,9 +1079,9 @@ export default function FinishedVideoPreview({
|
||||
].join(' ')}
|
||||
>
|
||||
{/* 1) Segmente (previewClips) als Markierungen */}
|
||||
{progressKind === 'teaser' && clipOverlay ? (
|
||||
{showTeaserSegments ? (
|
||||
<div className="absolute inset-0">
|
||||
{clipOverlay.map((c, i) => (
|
||||
{clipOverlay!.map((c, i) => (
|
||||
<div
|
||||
key={`seg-${i}-${c.left.toFixed(6)}-${c.width.toFixed(6)}`}
|
||||
className="absolute top-0 bottom-0 bg-white/15 dark:bg-white/20"
|
||||
@ -1054,22 +1096,15 @@ export default function FinishedVideoPreview({
|
||||
|
||||
{/* 2) Kontinuierlicher Fortschritt (SOLID, kein Gradient) */}
|
||||
<div
|
||||
className="absolute inset-0 origin-left transition-transform duration-150 ease-out"
|
||||
className={[
|
||||
'absolute inset-0 origin-left',
|
||||
progressKind === 'teaser' ? '' : 'transition-transform duration-150 ease-out',
|
||||
].join(' ')}
|
||||
style={{
|
||||
transform: `scaleX(${clamp01(progressRatio)})`,
|
||||
background: 'rgba(99,102,241,0.95)', // indigo-500-ish, kräftig
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 3) Knob am Ende (macht Progress sofort klar) */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-150"
|
||||
style={{
|
||||
left: `calc(${clamp01(progressRatio) * 100}% - 4px)`,
|
||||
}}
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white/90 shadow-[0_0_0_2px_rgba(0,0,0,0.25),0_0_10px_rgba(168,85,247,0.55)]" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -307,7 +307,7 @@ type BioResp = {
|
||||
// ------ props ------
|
||||
|
||||
// ------ API types (local model store) ------
|
||||
// /api/models/list liefert StoredModel aus dem models_store
|
||||
// /api/models liefert StoredModel aus dem models_store
|
||||
type StoredModel = {
|
||||
id: string
|
||||
modelKey: string
|
||||
@ -434,7 +434,7 @@ export default function ModelDetails({
|
||||
|
||||
const refetchModels = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/models/list', { cache: 'no-store' })
|
||||
const r = await fetch('/api/models', { cache: 'no-store' })
|
||||
const data = (await r.json().catch(() => null)) as any
|
||||
setModels(Array.isArray(data) ? data : [])
|
||||
} catch {
|
||||
@ -501,7 +501,7 @@ export default function ModelDetails({
|
||||
if (!open) return
|
||||
let alive = true
|
||||
setModelsLoading(true)
|
||||
fetch('/api/models/list', { cache: 'no-store' })
|
||||
fetch('/api/models', { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((data: StoredModel[]) => {
|
||||
if (!alive) return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -263,6 +263,7 @@ export type PlayerProps = {
|
||||
onStopJob?: (id: string) => void | Promise<void>
|
||||
|
||||
startMuted?: boolean
|
||||
startAtSec?: number
|
||||
}
|
||||
|
||||
export default function Player({
|
||||
@ -284,6 +285,7 @@ export default function Player({
|
||||
onToggleWatch,
|
||||
onStopJob,
|
||||
startMuted = DEFAULT_PLAYER_START_MUTED,
|
||||
startAtSec = 0
|
||||
}: PlayerProps) {
|
||||
const title = React.useMemo(
|
||||
() => baseName(job.output?.trim() || '') || job.id,
|
||||
@ -588,6 +590,14 @@ export default function Player({
|
||||
return baseName(job.output?.trim() || '') || job.id
|
||||
}, [job.output, job.id])
|
||||
|
||||
const normalizedStartAtSec = React.useMemo(() => {
|
||||
const n = Number(startAtSec)
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
||||
}, [startAtSec])
|
||||
|
||||
// Merkt sich, für welchen "Open-Zustand" wir den initialen Seek schon angewendet haben
|
||||
const appliedStartSeekRef = React.useRef<string>('')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isRunning) {
|
||||
setMetaReady(true)
|
||||
@ -895,6 +905,25 @@ export default function Player({
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const seekPlayerToAbsolute = React.useCallback((absSec: number) => {
|
||||
const p: any = playerRef.current
|
||||
if (!p || p.isDisposed?.()) return
|
||||
|
||||
const target = Math.max(0, Number(absSec) || 0)
|
||||
|
||||
try {
|
||||
// Shim ist installiert -> p.currentTime(...) interpretiert absolute Zeit korrekt
|
||||
const dur = Number(p.duration?.() ?? 0)
|
||||
const maxSeek = Number.isFinite(dur) && dur > 0 ? Math.max(0, dur - 0.05) : target
|
||||
p.currentTime(Math.min(target, maxSeek))
|
||||
p.trigger?.('timeupdate')
|
||||
} catch {
|
||||
try {
|
||||
p.currentTime(target)
|
||||
} catch {}
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return
|
||||
if (!isRunning && !metaReady) {
|
||||
@ -925,8 +954,12 @@ export default function Player({
|
||||
|
||||
// ✅ NICHT neu setzen, wenn Source identisch ist (verhindert "cancelled" durch unnötige Reloads)
|
||||
const curSrc = String((p as any).currentSrc?.() || '')
|
||||
|
||||
// ✅ immer zurücksetzen, sobald der Effekt für diese media.src läuft
|
||||
// (auch wenn wir die gleiche Source behalten)
|
||||
appliedStartSeekRef.current = ''
|
||||
|
||||
if (curSrc && curSrc === media.src) {
|
||||
// trotzdem versuchen zu spielen (z.B. wenn nur muted/state geändert wurde)
|
||||
const ret = p.play?.()
|
||||
if (ret && typeof (ret as any).catch === 'function') (ret as Promise<void>).catch(() => {})
|
||||
return
|
||||
@ -976,6 +1009,87 @@ export default function Player({
|
||||
tryPlay()
|
||||
}, [mounted, isRunning, metaReady, media.src, media.type, startMuted, updateIntrinsicDims, fullDurationSec, releaseMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return
|
||||
if (isRunning) return // Live spielt nicht über Video.js
|
||||
if (!metaReady) return
|
||||
if (!media.src) return
|
||||
|
||||
const p: any = playerRef.current
|
||||
if (!p || p.isDisposed?.()) return
|
||||
|
||||
// Nur seeken, wenn wirklich eine Startzeit angefordert wurde
|
||||
if (!(normalizedStartAtSec > 0)) {
|
||||
appliedStartSeekRef.current = ''
|
||||
return
|
||||
}
|
||||
|
||||
const seekSig = `${playbackKey}|${media.src}|${normalizedStartAtSec.toFixed(3)}`
|
||||
if (appliedStartSeekRef.current === seekSig) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const apply = () => {
|
||||
if (cancelled) return
|
||||
|
||||
const pp: any = playerRef.current
|
||||
if (!pp || pp.isDisposed?.()) return
|
||||
|
||||
// ✅ nur seeken, wenn die AKTUELLE source wirklich geladen ist
|
||||
const currentSrc = String(pp.currentSrc?.() || '')
|
||||
if (!currentSrc || currentSrc !== media.src) return
|
||||
|
||||
// readyState >= 1 => metadata verfügbar
|
||||
const techEl =
|
||||
pp.tech?.(true)?.el?.() ||
|
||||
pp.el?.()?.querySelector?.('video.vjs-tech')
|
||||
|
||||
const readyState =
|
||||
techEl instanceof HTMLVideoElement ? Number(techEl.readyState || 0) : 0
|
||||
|
||||
if (readyState < 1) return
|
||||
|
||||
seekPlayerToAbsolute(normalizedStartAtSec)
|
||||
appliedStartSeekRef.current = seekSig
|
||||
|
||||
try {
|
||||
const ret = pp.play?.()
|
||||
if (ret && typeof ret.catch === 'function') ret.catch(() => {})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ✅ Erst versuchen (falls schon geladen)
|
||||
apply()
|
||||
if (appliedStartSeekRef.current === seekSig) return
|
||||
|
||||
// ✅ Dann auf Events warten (neue Source lädt noch)
|
||||
const onLoaded = () => apply()
|
||||
p.one?.('loadedmetadata', onLoaded)
|
||||
p.one?.('canplay', onLoaded)
|
||||
p.one?.('durationchange', onLoaded)
|
||||
|
||||
// Extra fallback (manche Browser/Event-Reihenfolgen zickig)
|
||||
const t1 = window.setTimeout(apply, 0)
|
||||
const t2 = window.setTimeout(apply, 120)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(t1)
|
||||
window.clearTimeout(t2)
|
||||
try { p.off?.('loadedmetadata', onLoaded) } catch {}
|
||||
try { p.off?.('canplay', onLoaded) } catch {}
|
||||
try { p.off?.('durationchange', onLoaded) } catch {}
|
||||
}
|
||||
}, [
|
||||
mounted,
|
||||
isRunning,
|
||||
metaReady,
|
||||
media.src,
|
||||
playbackKey,
|
||||
normalizedStartAtSec,
|
||||
seekPlayerToAbsolute,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return
|
||||
const p = playerRef.current
|
||||
|
||||
@ -8,7 +8,7 @@ type Props = {
|
||||
imageCount: number
|
||||
activeIndex?: number
|
||||
onActiveIndexChange: (index: number | undefined) => void
|
||||
onClickIndex?: (index: number) => void
|
||||
onIndexClick?: (index: number) => void
|
||||
className?: string
|
||||
stepSeconds?: number
|
||||
}
|
||||
@ -28,7 +28,7 @@ export default function PreviewScrubber({
|
||||
imageCount,
|
||||
activeIndex,
|
||||
onActiveIndexChange,
|
||||
onClickIndex,
|
||||
onIndexClick,
|
||||
className,
|
||||
stepSeconds = 0,
|
||||
}: Props) {
|
||||
@ -110,11 +110,11 @@ export default function PreviewScrubber({
|
||||
const handleClick = React.useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
if (!onClickIndex) return
|
||||
if (!onIndexClick) return
|
||||
const idx = indexFromClientX(e.clientX)
|
||||
if (typeof idx === 'number') onClickIndex(idx)
|
||||
if (typeof idx === 'number') onIndexClick(idx)
|
||||
},
|
||||
[indexFromClientX, onClickIndex]
|
||||
[indexFromClientX, onIndexClick]
|
||||
)
|
||||
|
||||
if (!imageCount || imageCount < 1) return null
|
||||
@ -168,7 +168,7 @@ export default function PreviewScrubber({
|
||||
aria-valuemax={imageCount}
|
||||
aria-valuenow={typeof activeIndex === 'number' ? activeIndex + 1 : undefined}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-1 bottom-[3px] h-3 rounded-sm bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-white/35 ring-1 ring-white/40 backdrop-blur-[1px]">
|
||||
{typeof markerLeftPct === 'number' ? (
|
||||
<div
|
||||
className="absolute inset-y-0 w-[2px] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
|
||||
@ -182,7 +182,7 @@ export default function PreviewScrubber({
|
||||
|
||||
<div
|
||||
className={[
|
||||
'pointer-events-none absolute bottom-[17px] z-10',
|
||||
'pointer-events-none absolute bottom-[19px] z-10',
|
||||
'rounded bg-black/70 px-1.5 py-0.5',
|
||||
'text-[11px] leading-none text-white whitespace-nowrap',
|
||||
'transition-opacity duration-100',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user