nsfwapp/backend/preview.go
2026-05-12 09:25:27 +02:00

2256 lines
53 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend/preview.go
package main
import (
"bytes"
"context"
"crypto/sha1"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html"
"image"
"image/color"
"image/draw"
"image/jpeg"
"image/png"
"io"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// NOTE:
// Diese Datei ist ein "Zusammenzug" deiner bisherigen preview_* Dateien.
// Sie referenziert weiterhin vorhandene Functions/Globals aus deinem Backend, z.B.:
// - resolvePathRelativeToApp, getSettings, ensureAssetsForVideo, generatedThumbFile
// - atomicWriteFile, ensureGeneratedDirs, ensureGeneratedDir
// - durationSecondsCached, parseFFmpegOutTime, ffmpegInputTol
// - jobs, jobsMu, RecordJob, previewSem, thumbSem, JobRunning
// - sanitizeID, findFinishedFileByID, stripHotPrefix, assetIDForJob, generatedThumbJPGFile
// Bitte diese Abhängigkeiten NICHT löschen preview.go nutzt sie.
// ============================================================
// Shared wiring
// ============================================================
// coverModelStore wird von routes.go gesetzt (du rufst setCoverModelStore(store)).
var coverModelStore *ModelStore
func setCoverModelStore(s *ModelStore) { coverModelStore = s }
var errCoverNotSupported = errors.New("cover not supported")
//go:embed assets/img/cb.jpg assets/img/mfc.jpg
var embeddedFallbackImages embed.FS
// ============================================================
// Covers: generated/covers/<category>.<ext> + info.json
// Routes:
// - /api/generated/cover?category=...&refresh=1&model=...&src=...
// - /api/generated/coverinfo/list
// ============================================================
type coverInfo struct {
Category string `json:"category"`
Model string `json:"model,omitempty"`
Src string `json:"src,omitempty"`
GeneratedAt string `json:"generatedAt"`
}
func normalizeCoverSrc(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
// Windows -> URL-artig
s2 := strings.ReplaceAll(s, "\\", "/")
// Wenn es schon wie ein Web-Pfad aussieht, so lassen
if strings.HasPrefix(s2, "/generated/") || strings.HasPrefix(s2, "http://") || strings.HasPrefix(s2, "https://") {
return s2
}
// Wenn es ein lokaler Pfad ist, versuche den /generated/ Teil zu extrahieren
if i := strings.Index(s2, "/generated/"); i >= 0 {
return s2[i:]
}
return s2
}
func coversRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "covers"))
}
func ensureCoversDir() (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", appErrorf("covers root ist leer")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
func coverInfoPathForKey(key string) (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
return filepath.Join(root, key+".info.json"), nil
}
func writeCoverInfoBestEffort(key string, info coverInfo) {
p, err := coverInfoPathForKey(key)
if err != nil {
return
}
b, err := json.MarshalIndent(info, "", " ")
if err != nil {
return
}
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, b, 0o644)
}
func readCoverInfoBestEffort(key string) (coverInfo, bool) {
p, err := coverInfoPathForKey(key)
if err != nil {
return coverInfo{}, false
}
b, err := os.ReadFile(p)
if err != nil || len(b) == 0 {
return coverInfo{}, false
}
var ci coverInfo
if json.Unmarshal(b, &ci) != nil {
return coverInfo{}, false
}
return ci, true
}
func drawLabel(img draw.Image, text string) {
text = strings.TrimSpace(text)
if text == "" {
return
}
face := basicfont.Face7x13
// Layout
const margin = 10
const padX = 10
const padY = 8
b := img.Bounds()
maxTextW := (b.Dx() - 2*margin) - 2*padX
if maxTextW <= 0 {
return
}
measure := func(s string) int {
d := &font.Drawer{Face: face}
return d.MeasureString(s).Ceil()
}
label := text
if w := measure(label); w > maxTextW {
ellipsis := "…"
rs := []rune(text)
if len(rs) == 0 {
return
}
lo, hi := 0, len(rs)
best := ""
for lo <= hi {
mid := (lo + hi) / 2
cand := string(rs[:mid]) + ellipsis
if measure(cand) <= maxTextW {
best = cand
lo = mid + 1
} else {
hi = mid - 1
}
}
if best == "" {
label = ellipsis
} else {
label = best
}
}
d := &font.Drawer{Face: face}
textW := d.MeasureString(label).Ceil()
textH := face.Metrics().Height.Ceil()
ascent := face.Metrics().Ascent.Ceil()
x0 := b.Min.X + margin
y1 := b.Max.Y - margin
y0 := y1 - (textH + 2*padY)
x1 := x0 + (textW + 2*padX)
maxX1 := b.Max.X - margin
if x1 > maxX1 {
shift := x1 - maxX1
x0 -= shift
x1 -= shift
if x0 < b.Min.X+margin {
x0 = b.Min.X + margin
x1 = maxX1
}
}
minY0 := b.Min.Y + margin
if y0 < minY0 {
y0 = minY0
y1 = y0 + (textH + 2*padY)
if y1 > b.Max.Y-margin {
return
}
}
rect := image.Rect(x0, y0, x1, y1)
bg := image.NewUniform(color.RGBA{0, 0, 0, 170})
draw.Draw(img, rect, bg, image.Point{}, draw.Over)
border := image.NewUniform(color.RGBA{255, 255, 255, 35})
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Min.Y+1), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Min.X, rect.Max.Y-1, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+1, rect.Max.Y), border, image.Point{}, draw.Over)
draw.Draw(img, image.Rect(rect.Max.X-1, rect.Min.Y, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
tx := x0 + padX
ty := y0 + padY + ascent
shadow := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{0, 0, 0, 200}), Face: face, Dot: fixed.P(tx+1, ty+1)}
shadow.DrawString(label)
fg := &font.Drawer{Dst: img, Src: image.NewUniform(color.RGBA{255, 255, 255, 235}), Face: face, Dot: fixed.P(tx, ty)}
fg.DrawString(label)
}
func splitTagsLoose(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case '\n', ',', ';', '|':
return true
}
return false
})
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
low := strings.ToLower(t)
if _, ok := seen[low]; ok {
continue
}
seen[low] = struct{}{}
out = append(out, t)
}
return out
}
func hasTag(tagsRaw string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" {
return false
}
for _, t := range splitTagsLoose(tagsRaw) {
if strings.ToLower(strings.TrimSpace(t)) == want {
return true
}
}
return false
}
type coverModel struct {
Key string
Tags string
}
func listModelsForCovers() ([]coverModel, error) {
if coverModelStore == nil {
return nil, appErrorf("model store not set")
}
ms := coverModelStore.List()
out := make([]coverModel, 0, len(ms))
for _, m := range ms {
key := strings.TrimSpace(m.ModelKey)
if key == "" {
continue
}
out = append(out, coverModel{Key: key, Tags: m.Tags})
}
return out, nil
}
func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) {
category = strings.TrimSpace(category)
if category == "" {
return "", appErrorf("category empty")
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
models, err := listModelsForCovers()
if err != nil {
return "", err
}
cands := make([]coverModel, 0, 64)
for _, m := range models {
key := strings.TrimSpace(m.Key)
if key == "" {
continue
}
if hasTag(m.Tags, category) {
cands = append(cands, coverModel{Key: key, Tags: m.Tags})
}
}
if len(cands) == 0 {
return "", appErrorf("no model with tag")
}
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
s := getSettings()
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
if derr != nil || strings.TrimSpace(doneAbs) == "" {
return "", appErrorf("doneDir resolve failed: %v", derr)
}
type candFile struct {
videoPath string
id string
}
isVideo := func(name string) bool {
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
return false
}
ext := strings.ToLower(filepath.Ext(name))
return ext == ".mp4"
}
for _, m := range cands {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
modelKey := strings.TrimSpace(m.Key)
if modelKey == "" {
continue
}
dirs := []string{filepath.Join(doneAbs, modelKey), filepath.Join(doneAbs, "keep", modelKey)}
files := make([]candFile, 0, 128)
for _, d := range dirs {
ents, err := os.ReadDir(d)
if err != nil {
continue
}
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
if !isVideo(name) {
continue
}
full := filepath.Join(d, name)
stem := strings.TrimSuffix(name, filepath.Ext(name))
id := stripHotPrefix(strings.TrimSpace(stem))
if id == "" {
continue
}
files = append(files, candFile{videoPath: full, id: id})
}
}
if len(files) == 0 {
continue
}
cf := files[rand.Intn(len(files))]
_ = ensureAssetsForVideo(cf.videoPath)
tp, terr := generatedThumbFile(cf.id)
if terr != nil {
continue
}
if fi, serr := os.Stat(tp); serr == nil && !fi.IsDir() && fi.Size() > 0 {
return tp, nil
}
}
return "", appErrorf("no downloads/thumbs for category")
}
var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`)
func sanitizeCoverKey(category string) (string, error) {
c := strings.ToLower(strings.TrimSpace(category))
if c == "" {
sum := sha1.Sum([]byte(category))
c = "tag_" + hex.EncodeToString(sum[:8])
}
if c == "" {
return "", appErrorf("category fehlt")
}
c = strings.ReplaceAll(c, " ", "_")
c = coverKeyRe.ReplaceAllString(c, "_")
c = strings.Trim(c, "._-")
if c == "" {
return "", appErrorf("category ungültig")
}
if len(c) > 120 {
c = c[:120]
}
return c, nil
}
func detectImageExt(contentType string, b []byte) (ext string, ct string) {
ct = strings.ToLower(strings.TrimSpace(contentType))
switch {
case strings.Contains(ct, "image/jpeg") || strings.Contains(ct, "image/jpg"):
return ".jpg", "image/jpeg"
case strings.Contains(ct, "image/png"):
return ".png", "image/png"
case strings.Contains(ct, "image/gif"):
return ".gif", "image/gif"
}
if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
return ".jpg", "image/jpeg"
}
if len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
return ".png", "image/png"
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return ".jpg", "image/jpeg"
}
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
return ".gif", "image/gif"
}
return ".jpg", "image/jpeg"
}
func coverPathForCategory(key string, ext string) (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", appErrorf("covers root ist leer")
}
if ext == "" {
ext = ".jpg"
}
return filepath.Join(root, key+ext), nil
}
func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
root, err := coversRoot()
if err != nil || strings.TrimSpace(root) == "" {
return "", nil, false
}
ext := []string{".jpg", ".png", ".gif"}
for _, e := range ext {
p := filepath.Join(root, key+e)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
return p, fi, true
}
}
return "", nil, false
}
func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, "", appErrorf("src fehlt")
}
// local: only /generated/...
if strings.HasPrefix(rawURL, "/") {
clean := path.Clean(rawURL)
if !strings.HasPrefix(clean, "/generated/") {
return nil, "", appErrorf("src ungültig")
}
if strings.Contains(clean, "..") {
return nil, "", appErrorf("src ungültig")
}
rel := strings.TrimPrefix(clean, "/")
abs, err := resolvePathRelativeToApp(rel)
if err != nil || strings.TrimSpace(abs) == "" {
return nil, "", appErrorf("src ungültig")
}
f, err := os.Open(abs)
if err != nil {
return nil, "", appErrorf("download failed: %v", err)
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
if err != nil {
return nil, "", appErrorf("download failed: %v", err)
}
if len(b) == 0 {
return nil, "", appErrorf("download empty")
}
ext := strings.ToLower(filepath.Ext(abs))
ct := "application/octet-stream"
switch ext {
case ".jpg", ".jpeg":
ct = "image/jpeg"
case ".png":
ct = "image/png"
case ".gif":
ct = "image/gif"
}
return b, ct, nil
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", appErrorf("src ungültig")
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", appErrorf("src schema nicht erlaubt")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(ua) == "" {
ua = "Mozilla/5.0"
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "image/*,*/*;q=0.8")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", appErrorf("download failed: HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return nil, "", err
}
if len(b) == 0 {
return nil, "", appErrorf("download empty")
}
return b, resp.Header.Get("Content-Type"), nil
}
var coverBatchMu sync.Mutex
var (
coverBatchInflight int
coverBatchStarted time.Time
coverBatchTotal int
coverBatchForced int
coverBatchMiss int
coverBatchErrors int
coverBatchNoThumb int
coverBatchDecodeErr int
)
func coverBatchEnter(force bool) {
coverBatchMu.Lock()
defer coverBatchMu.Unlock()
if coverBatchInflight == 0 {
coverBatchStarted = time.Now()
coverBatchTotal = 0
coverBatchForced = 0
coverBatchMiss = 0
coverBatchErrors = 0
coverBatchNoThumb = 0
coverBatchDecodeErr = 0
}
coverBatchInflight++
coverBatchTotal++
if force {
coverBatchForced++
} else {
coverBatchMiss++
}
}
func coverBatchLeave(outcome string, status int) {
coverBatchMu.Lock()
defer coverBatchMu.Unlock()
if status >= 400 {
coverBatchErrors++
}
switch outcome {
case "no-thumb":
coverBatchNoThumb++
case "decode-failed-no-overlay":
coverBatchDecodeErr++
}
coverBatchInflight--
}
var reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`)
func inferModelFromStem(stem string) string {
stem = stripHotPrefix(strings.TrimSpace(stem))
if stem == "" {
return ""
}
m := reModelFromStem.FindStringSubmatch(stem)
if len(m) >= 2 {
return strings.TrimSpace(m[1])
}
return ""
}
func inferModelFromThumbLike(srcOrPath string) string {
s := strings.TrimSpace(srcOrPath)
if s == "" {
return ""
}
s = strings.ReplaceAll(s, `\\`, `/`)
if u, err := url.Parse(s); err == nil && u != nil && u.Scheme != "" && u.Host != "" {
s = u.Path
}
base := path.Base(s)
lb := strings.ToLower(base)
if strings.HasPrefix(lb, "thumbs.") {
id := path.Base(path.Dir(s))
return inferModelFromStem(id)
}
stem := strings.TrimSuffix(base, path.Ext(base))
return inferModelFromStem(stem)
}
type coverInfoListItem struct {
Category string `json:"category"`
Model string `json:"model,omitempty"`
GeneratedAt string `json:"generatedAt,omitempty"`
HasCover bool `json:"hasCover"`
}
func generatedCoverInfoList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
root, err := coversRoot()
if err != nil {
http.Error(w, "covers root: "+err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(root)
if err != nil {
http.Error(w, "covers dir: "+err.Error(), http.StatusInternalServerError)
return
}
byKey := map[string]*coverInfoListItem{}
ensure := func(key string) *coverInfoListItem {
if v, ok := byKey[key]; ok {
return v
}
v := &coverInfoListItem{Category: key}
byKey[key] = v
return v
}
isCoverExt := func(ext string) bool {
switch strings.ToLower(ext) {
case ".jpg", ".jpeg", ".png", ".gif":
return true
default:
return false
}
}
for _, e := range entries {
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".info.json") {
key := strings.TrimSuffix(name, ".info.json")
if ci, ok := readCoverInfoBestEffort(key); ok {
v := ensure(key)
if strings.TrimSpace(ci.Category) != "" {
v.Category = strings.TrimSpace(ci.Category)
}
if strings.TrimSpace(ci.Model) != "" {
v.Model = strings.TrimSpace(ci.Model)
}
if strings.TrimSpace(ci.GeneratedAt) != "" {
v.GeneratedAt = strings.TrimSpace(ci.GeneratedAt)
}
}
continue
}
ext := filepath.Ext(name)
if isCoverExt(ext) {
key := strings.TrimSuffix(name, ext)
v := ensure(key)
v.HasCover = true
}
}
for _, v := range byKey {
if !v.HasCover {
v.Model = ""
v.GeneratedAt = ""
}
if strings.TrimSpace(v.Category) == "" {
v.Category = ""
}
}
keys := make([]string, 0, len(byKey))
for k := range byKey {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]coverInfoListItem, 0, len(keys))
for _, k := range keys {
out = append(out, *byKey[k])
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(w).Encode(out)
}
func generatedCover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
category := r.URL.Query().Get("category")
key, err := sanitizeCoverKey(category)
if err != nil {
http.Error(w, "category ungültig: "+err.Error(), http.StatusBadRequest)
return
}
refresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
force := refresh == "1" || refresh == "true" || refresh == "yes"
modelQ := strings.TrimSpace(r.URL.Query().Get("model"))
modelExplicit := modelQ != ""
model := modelQ
src := strings.TrimSpace(r.URL.Query().Get("src"))
fallbackModel := ""
if ci, ok := readCoverInfoBestEffort(key); ok {
if m := strings.TrimSpace(ci.Model); m != "" {
fallbackModel = m
}
}
if model == "" {
model = fallbackModel
}
if !modelExplicit && src != "" {
if m := inferModelFromThumbLike(src); m != "" {
model = m
}
}
reqID := strconv.FormatInt(time.Now().UnixNano(), 36)
setDebugHeaders := func(cache string) {
w.Header().Set("X-Cover-Key", key)
w.Header().Set("X-Cover-Category", category)
if model != "" {
w.Header().Set("X-Cover-Model", model)
}
w.Header().Set("X-Cover-Cache", cache)
w.Header().Set("X-Request-Id", reqID)
}
if !force {
if model != "" {
if ci, ok := readCoverInfoBestEffort(key); ok {
if strings.TrimSpace(ci.Model) != model {
force = true
}
} else {
force = true
}
}
if !force {
if p, fi, ok := findExistingCoverFile(key); ok {
setDebugHeaders("HIT")
if model != "" {
ci, ok := readCoverInfoBestEffort(key)
if !ok {
ci = coverInfo{Category: category}
}
ci.Category = category
ci.Model = strings.TrimSpace(model)
ci.GeneratedAt = time.Now().UTC().Format(time.RFC3339Nano)
writeCoverInfoBestEffort(key, ci)
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext := strings.ToLower(filepath.Ext(p))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
}
}
cacheStatus := "MISS"
if force {
cacheStatus = "FORCED"
}
setDebugHeaders(cacheStatus)
coverBatchEnter(force)
start := time.Now()
status := http.StatusOK
outcome := "ok"
defer func() {
w.Header().Set("X-Cover-Gen-Ms", strconv.FormatInt(time.Since(start).Milliseconds(), 10))
coverBatchLeave(outcome, status)
}()
if _, err := ensureCoversDir(); err != nil {
status = http.StatusInternalServerError
outcome = "covers-dir"
http.Error(w, "covers-dir nicht verfügbar: "+err.Error(), status)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var (
raw []byte
mimeType string
ext string
)
thumbPath := ""
usedSrc := ""
if src != "" {
var derr error
raw, mimeType, derr = downloadBytes(ctx, src, r.Header.Get("User-Agent"))
usedSrc = normalizeCoverSrc(src)
if derr != nil {
status = http.StatusBadRequest
outcome = "src-download"
http.Error(w, "src download failed: "+derr.Error(), status)
return
}
ext, mimeType = detectImageExt(mimeType, raw)
if len(raw) == 0 {
status = http.StatusBadRequest
outcome = "src-empty"
http.Error(w, "src leer", status)
return
}
if model == "" {
if m := inferModelFromThumbLike(src); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
} else {
var perr error
thumbPath, perr = pickRandomThumbForCategory(ctx, category)
if perr != nil {
if p, fi, ok := findExistingCoverFile(key); ok {
outcome = "fallback-existing-cover"
status = http.StatusOK
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext2 := strings.ToLower(filepath.Ext(p))
switch ext2 {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
servePreviewStatusSVG(w, "No Cover", status)
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
outcome = "no-thumb"
status = http.StatusNotFound
if r.Method == http.MethodHead {
w.WriteHeader(status)
return
}
servePreviewStatusSVG(w, "No Cover", status)
return
}
usedSrc = normalizeCoverSrc(thumbPath)
raw, err = os.ReadFile(thumbPath)
if err != nil || len(raw) == 0 {
status = http.StatusInternalServerError
outcome = "thumb-read"
http.Error(w, "cover read fehlgeschlagen", status)
return
}
ext = ".jpg"
mimeType = "image/jpeg"
if model == "" {
if m := inferModelFromThumbLike(thumbPath); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
}
if !modelExplicit {
if m := inferModelFromThumbLike(usedSrc); m != "" {
model = m
w.Header().Set("X-Cover-Model", model)
}
}
img, _, derr := image.Decode(bytes.NewReader(raw))
if derr == nil && img != nil {
rgba := image.NewRGBA(img.Bounds())
draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src)
if strings.TrimSpace(model) != "" {
drawLabel(rgba, model)
}
var buf bytes.Buffer
switch strings.ToLower(ext) {
case ".png":
_ = png.Encode(&buf, rgba)
raw = buf.Bytes()
ext = ".png"
mimeType = "image/png"
default:
_ = jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: 85})
raw = buf.Bytes()
ext = ".jpg"
mimeType = "image/jpeg"
}
} else {
outcome = "decode-failed-no-overlay"
}
root, _ := coversRoot()
for _, e := range []string{".jpg", ".png", ".gif"} {
_ = os.Remove(filepath.Join(root, key+e))
}
_ = os.Remove(filepath.Join(root, key+".info.json"))
dst, err := coverPathForCategory(key, ext)
if err != nil {
status = http.StatusInternalServerError
outcome = "cover-path"
http.Error(w, "cover path: "+err.Error(), status)
return
}
if err := atomicWriteFile(dst, raw); err != nil {
status = http.StatusInternalServerError
outcome = "cover-write"
http.Error(w, "cover write: "+err.Error(), status)
return
}
writeCoverInfoBestEffort(key, coverInfo{
Category: category,
Model: strings.TrimSpace(model),
Src: strings.TrimSpace(usedSrc),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("Content-Type", mimeType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Cover-Bytes", strconv.Itoa(len(raw)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
// ============================================================
// Status SVG (Preview placeholder)
// ============================================================
func servePreviewStatusSVG(w http.ResponseWriter, label string, status int) {
w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
if status <= 0 {
status = http.StatusOK
}
title := html.EscapeString(strings.TrimSpace(label))
if title == "" {
title = "Preview"
}
svg := `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="rgba(99,102,241,0.10)"/>
<stop offset="1" stop-color="rgba(14,165,233,0.08)"/>
</linearGradient>
<radialGradient id="vig" cx="50%" cy="45%" r="75%">
<stop offset="0" stop-color="rgba(0,0,0,0)"/>
<stop offset="1" stop-color="rgba(0,0,0,0.18)"/>
</radialGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-color="rgba(0,0,0,0.18)"/>
</filter>
</defs>
<rect x="0" y="0" width="320" height="180" rx="18" fill="rgba(17,24,39,0.06)"/>
<rect x="0" y="0" width="320" height="180" rx="18" fill="url(#bg)"/>
<rect x="0" y="0" width="320" height="180" rx="18" fill="url(#vig)"/>
<g filter="url(#shadow)">
<rect x="18" y="18" width="284" height="144" rx="16"
fill="rgba(255,255,255,0.72)"
stroke="rgba(0,0,0,0.08)"/>
<rect x="18" y="18" width="284" height="144" rx="16"
fill="rgba(255,255,255,0)"
stroke="rgba(99,102,241,0.18)"
stroke-width="2"
stroke-dasharray="6 6"/>
</g>
<g transform="translate(160 70)">
<circle r="20" fill="rgba(17,24,39,0.08)" stroke="rgba(0,0,0,0.08)"/>
<path d="M-10 6 L-4 0 L2 6 L10 -2" fill="none" stroke="rgba(17,24,39,0.55)" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M-10 -6 H10" fill="none" stroke="rgba(17,24,39,0.35)" stroke-width="2.4" stroke-linecap="round"/>
<path d="M-12 12 L12 -12" fill="none" stroke="rgba(239,68,68,0.55)" stroke-width="2.6" stroke-linecap="round"/>
</g>
<text x="160" y="118" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto"
font-size="16" font-weight="750"
fill="rgba(17,24,39,0.88)">` + title + `</text>
<text x="160" y="140" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto"
font-size="11.5" font-weight="650"
fill="rgba(75,85,99,0.82)">Preview nicht verfügbar</text>
</svg>
`
w.WriteHeader(status)
_, _ = w.Write([]byte(svg))
}
func shortFFmpegStderr(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lines := strings.Split(raw, "\n")
clean := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
clean = append(clean, line)
}
if len(clean) == 0 {
return ""
}
want := []string{
"Error while opening encoder",
"Could not open encoder",
"Invalid argument",
"Nothing was written into output file",
"Non full-range YUV",
"moov atom not found",
"Invalid data found",
"Error opening input",
}
for _, needle := range want {
needleLower := strings.ToLower(needle)
for _, line := range clean {
if strings.Contains(strings.ToLower(line), needleLower) {
if len(clean) > 1 {
return fmt.Sprintf("%s … (+%d Zeilen)", line, len(clean)-1)
}
return line
}
}
}
if len(clean) == 1 {
return clean[0]
}
return fmt.Sprintf("%s … (+%d Zeilen)", clean[len(clean)-1], len(clean)-1)
}
func ffmpegPreviewError(label string, err error, stderr string) error {
msg := shortFFmpegStderr(stderr)
if msg == "" {
return appErrorf("%s: %w", label, err)
}
return appErrorf("%s: %w: %s", label, err, msg)
}
// ============================================================
// JPG extraction + preview endpoint
// Route:
// - /api/preview?id=<jobID> (returns preview.jpg / 204 / svg)
// - /api/preview?id=<jobID>&file=preview.jpg
// ============================================================
// --- JPG extraction helpers ---
func extractLastFrameJPG(path string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-sseof", "-0.25",
"-i", path,
"-map", "0:v:0",
"-an",
"-sn",
"-dn",
"-frames:v", "1",
"-vf", "scale=720:-2:flags=fast_bilinear",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, appErrorf("ffmpeg last-frame jpg: timeout")
}
return nil, ffmpegPreviewError("ffmpeg last-frame jpg", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg last-frame jpg: empty output")
}
return b, nil
}
func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
if seconds < 0 {
seconds = 0
}
seek := fmt.Sprintf("%.3f", seconds)
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", seek,
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "4",
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg frame-at-time jpg: empty output")
}
return b, nil
}
func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
qv := "5"
if quality >= 80 {
qv = "3"
} else if quality >= 65 {
qv = "5"
} else {
qv = "7"
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-sseof", "-0.25",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", qv,
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, ffmpegPreviewError("ffmpeg last-frame scaled jpg", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg last-frame scaled jpg: empty output")
}
return b, nil
}
func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, error) {
if width <= 0 {
width = 320
}
if quality <= 0 || quality > 100 {
quality = 70
}
cmd := exec.Command(
ffmpegPath,
"-hide_banner", "-loglevel", "error",
"-ss", "0",
"-i", path,
"-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:-2", width),
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "5",
"-f", "image2pipe",
"pipe:1",
)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, ffmpegPreviewError("ffmpeg first-frame scaled jpg", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg first-frame scaled jpg: empty output")
}
return b, nil
}
func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) {
m3u8URL = strings.TrimSpace(m3u8URL)
if m3u8URL == "" {
return nil, appErrorf("m3u8 leer")
}
if strings.TrimSpace(userAgent) == "" {
userAgent = "Mozilla/5.0"
}
args := []string{
"-hide_banner",
"-loglevel", "error",
}
if strings.TrimSpace(userAgent) != "" {
args = append(args, "-user_agent", strings.TrimSpace(userAgent))
}
if strings.TrimSpace(cookie) != "" {
args = append(args, "-headers", fmt.Sprintf("Cookie: %s\r\n", strings.TrimSpace(cookie)))
}
args = append(args,
"-i", m3u8URL,
"-frames:v", "1",
"-vf", "scale=320:-2",
"-vcodec", "mjpeg",
"-pix_fmt", "yuvj420p",
"-threads", "1",
"-q:v", "6",
"-f", "image2pipe",
"pipe:1",
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, ffmpegPreviewError("ffmpeg live m3u8 jpg failed", err, stderr.String())
}
b := out.Bytes()
if len(b) == 0 {
return nil, appErrorf("ffmpeg live m3u8 jpg: empty output")
}
return b, nil
}
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
http.ServeContent(w, r, "preview.jpg", st.ModTime(), f)
}
func servePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() || st.Size() == 0 {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=600")
http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f)
}
func servePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func serveLivePreviewJPGBytes(w http.ResponseWriter, b []byte) {
if len(b) == 0 {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
func detectPreviewProvider(job *RecordJob, outPath string) string {
if job != nil {
sourceURL := strings.ToLower(strings.TrimSpace(job.SourceURL))
switch {
case strings.Contains(sourceURL, "chaturbate.com"):
return "chaturbate"
case strings.Contains(sourceURL, "myfreecams.com"):
return "mfc"
}
}
modelKey := strings.TrimSpace(inferModelFromThumbLike(outPath))
if modelKey == "" || coverModelStore == nil {
return ""
}
modelKey = strings.ToLower(modelKey)
models := coverModelStore.List()
for _, m := range models {
if strings.ToLower(strings.TrimSpace(m.ModelKey)) != modelKey {
continue
}
host := strings.ToLower(strings.TrimSpace(m.Host))
input := strings.ToLower(strings.TrimSpace(m.Input))
switch {
case strings.Contains(host, "chaturbate"), strings.Contains(input, "chaturbate.com"):
return "chaturbate"
case strings.Contains(host, "myfreecams"), strings.Contains(input, "myfreecams.com"):
return "mfc"
}
}
return ""
}
func fallbackPreviewImageAssetPathForProvider(provider string) (string, error) {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "chaturbate":
return "assets/img/cb.jpg", nil
case "mfc":
return "assets/img/mfc.jpg", nil
default:
return "", appErrorf("unknown provider")
}
}
func serveFallbackPreviewImage(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string) bool {
provider := detectPreviewProvider(job, outPath)
if provider == "" {
return false
}
assetPath, err := fallbackPreviewImageAssetPathForProvider(provider)
if err != nil {
return false
}
b, err := embeddedFallbackImages.ReadFile(assetPath)
if err != nil || len(b) == 0 {
return false
}
ext := strings.ToLower(filepath.Ext(assetPath))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
w.Header().Set("Cache-Control", "public, max-age=60")
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return true
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
return true
}
func serveProviderFallbackOrStatus(w http.ResponseWriter, r *http.Request, job *RecordJob, outPath string, label string, status int) {
if serveFallbackPreviewImage(w, r, job, outPath) {
return
}
servePreviewStatusSVG(w, label, status)
}
func serveRunningJobPreview(w http.ResponseWriter, r *http.Request, job *RecordJob) bool {
if job == nil {
return false
}
provider := detectPreviewProvider(job, job.Output)
previewState := previewStateForJob(job)
imageURL := strings.TrimSpace(previewImageURLForJob(job))
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
previewCookie := strings.TrimSpace(job.PreviewCookie)
previewUA := strings.TrimSpace(job.PreviewUA)
outPath := strings.TrimSpace(job.Output)
// Chaturbate: immer CDN bevorzugen, nichts auf Platte schreiben
if provider == "chaturbate" && imageURL != "" {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if img, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(img) > 0 {
job.previewMu.Lock()
job.previewJPG = append([]byte(nil), img...)
job.previewJPGAt = time.Now()
job.previewMu.Unlock()
serveLivePreviewJPGBytes(w, img)
return true
}
}
job.previewMu.Lock()
cached := append([]byte(nil), job.previewJPG...)
cachedAt := job.previewJPGAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 18*time.Second
canSpawn := !fresh && !job.previewGen && provider != "chaturbate"
if canSpawn {
job.previewGen = true
}
job.previewMu.Unlock()
if fresh || (provider == "chaturbate" && len(cached) > 0) {
serveLivePreviewJPGBytes(w, cached)
return true
}
// Für Nicht-Chaturbate darf weiterhin in-memory erzeugt werden, aber nicht als preview.jpg speichern
if canSpawn {
go func(j *RecordJob, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
if previewState == "public" && imageURL != "" {
if b, err := downloadPreviewImageJPG(context.Background(), imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && previewM3U8 != "" {
if b, err := extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && outPath != "" {
if b, err := extractLastFrameJPGScaled(outPath, 720, 75); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 {
return
}
j.previewMu.Lock()
j.previewJPG = append([]byte(nil), img...)
j.previewJPGAt = time.Now()
j.previewMu.Unlock()
}(job, previewState, imageURL, previewM3U8, previewCookie, previewUA, outPath)
}
return false
}
func servePreviewJPGAlias(w http.ResponseWriter, r *http.Request, id string) {
jobsMu.RLock()
job := jobs[id]
jobsMu.RUnlock()
if job != nil {
if job.Status == JobRunning {
if serveRunningJobPreview(w, r, job) {
return
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
assetID := assetIDForJob(job)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewJPGFile(w, r, jpgPath)
return
}
}
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
assetID := stripHotPrefix(strings.TrimSpace(id))
if assetID == "" {
http.NotFound(w, r)
return
}
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewJPGFile(w, r, jpgPath)
return
}
}
http.NotFound(w, r)
}
func recordPreview(w http.ResponseWriter, r *http.Request) {
// Standard: rewrite soll auf /api/preview zeigen
recordPreviewWithBase(w, r, "/api/preview")
}
func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
id = strings.TrimSpace(r.URL.Query().Get("name"))
}
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
fallbackOnlyRaw := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("fallbackOnly")))
fallbackOnly := fallbackOnlyRaw == "1" || fallbackOnlyRaw == "true" || fallbackOnlyRaw == "yes"
if fallbackOnly {
jobsMu.RLock()
job, ok := jobs[id]
jobsMu.RUnlock()
if ok {
if serveFallbackPreviewImage(w, r, job, job.Output) {
return
}
servePreviewStatusSVG(w, "Preview", http.StatusOK)
return
}
assetID := stripHotPrefix(strings.TrimSpace(id))
if assetID != "" {
if outPath, err := findFinishedFileByID(assetID); err == nil {
if serveFallbackPreviewImage(w, r, nil, outPath) {
return
}
}
}
servePreviewStatusSVG(w, "Preview", http.StatusOK)
return
}
// file serving
if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" {
low := strings.ToLower(strings.TrimSpace(file))
if low == "preview.jpg" {
jobsMu.RLock()
job := jobs[id]
jobsMu.RUnlock()
if job != nil && job.Status == JobRunning {
startLiveThumbJPGLoop(context.Background(), job)
}
servePreviewJPGAlias(w, r, id)
return
}
recordPreviewFile(w, r)
return
}
// JPG preview (running jobs have live thumb behavior)
jobsMu.RLock()
job, ok := jobs[id]
jobsMu.RUnlock()
if ok {
if job.Status == JobRunning {
if serveRunningJobPreview(w, r, job) {
return
}
state := strings.TrimSpace(previewStateForJob(job))
if state == "private" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Private", http.StatusOK)
return
}
if state == "offline" {
serveProviderFallbackOrStatus(w, r, job, job.Output, "Offline", http.StatusOK)
return
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
assetID := assetIDForJob(job)
if assetID != "" {
if jpgPath, err := generatedThumbJPGFile(assetID); err == nil {
if st, err := os.Stat(jpgPath); err == nil && !st.IsDir() && st.Size() > 0 {
servePreviewJPGFile(w, r, jpgPath)
return
}
}
}
serveProviderFallbackOrStatus(w, r, job, job.Output, "Preview", http.StatusOK)
return
}
// Finished file preview
servePreviewForFinishedFile(w, r, id)
}
func reflectStringField(v any, names ...string) string {
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return ""
}
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
rv = rv.Elem()
}
if !rv.IsValid() || rv.Kind() != reflect.Struct {
return ""
}
for _, name := range names {
f := rv.FieldByName(name)
if !f.IsValid() || f.Kind() != reflect.String {
continue
}
s := strings.TrimSpace(f.String())
if s != "" {
return s
}
}
return ""
}
func inferModelFromSourceURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil || u == nil {
return ""
}
host := strings.ToLower(strings.TrimPrefix(u.Hostname(), "www."))
switch {
case strings.Contains(host, "chaturbate.com"):
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) > 0 {
return strings.ToLower(strings.TrimSpace(parts[0]))
}
case strings.Contains(host, "myfreecams.com"):
hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/")
if hash != "" {
parts := strings.Split(hash, "/")
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return strings.ToLower(last)
}
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) > 0 {
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
return strings.ToLower(last)
}
}
}
return ""
}
func previewModelKeyForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job, "Model", "ModelKey"); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
if s := reflectStringField(job, "SourceURL", "SourceUrl"); s != "" {
if mk := inferModelFromSourceURL(s); mk != "" {
return mk
}
}
if s := reflectStringField(job, "Output"); s != "" {
if mk := inferModelFromThumbLike(s); mk != "" {
return strings.ToLower(strings.TrimSpace(mk))
}
}
return ""
}
func previewStateForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job,
"PreviewState",
"RoomStatus",
"ModelStatus",
"CurrentShow",
"Show",
"StatusText",
); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
modelKey := previewModelKeyForJob(job)
if modelKey == "" || coverModelStore == nil {
return ""
}
models := coverModelStore.List()
for _, m := range models {
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
if mk == "" || mk != modelKey {
continue
}
if s := reflectStringField(m,
"RoomStatus",
"CurrentShow",
"Status",
"ModelStatus",
); s != "" {
return strings.ToLower(strings.TrimSpace(s))
}
}
return ""
}
func previewImageURLForJob(job *RecordJob) string {
if job == nil {
return ""
}
if s := reflectStringField(job,
"ModelImageURL",
"ModelImageUrl",
"ImageURL",
"ImageUrl",
"PreviewImageURL",
"PreviewImageUrl",
); s != "" {
return s
}
modelKey := previewModelKeyForJob(job)
if modelKey == "" || coverModelStore == nil {
return ""
}
models := coverModelStore.List()
for _, m := range models {
mk := strings.ToLower(strings.TrimSpace(reflectStringField(m, "ModelKey")))
if mk == "" || mk != modelKey {
continue
}
if s := reflectStringField(m,
"ImageURL",
"ImageUrl",
"ModelImageURL",
"ModelImageUrl",
); s != "" {
return s
}
}
return ""
}
func imageBytesToJPG(raw []byte, quality int) ([]byte, error) {
if len(raw) == 0 {
return nil, appErrorf("empty image")
}
if quality <= 0 || quality > 100 {
quality = 80
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, err
}
if buf.Len() == 0 {
return nil, appErrorf("jpeg encode returned empty data")
}
return buf.Bytes(), nil
}
func downloadPreviewImageJPG(ctx context.Context, rawURL, ua string) ([]byte, error) {
raw, _, err := downloadBytes(ctx, rawURL, ua)
if err != nil {
return nil, err
}
return imageBytesToJPG(raw, 82)
}
func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) {
jobsMu.RLock()
status := job.Status
previewM3U8 := strings.TrimSpace(job.PreviewM3U8)
previewCookie := strings.TrimSpace(job.PreviewCookie)
previewUA := strings.TrimSpace(job.PreviewUA)
out := job.Output
jobsMu.RUnlock()
if status != JobRunning {
return
}
job.previewMu.Lock()
lastAt := job.previewJPGAt
job.previewMu.Unlock()
if !lastAt.IsZero() && time.Since(lastAt) < 18*time.Second {
return
}
if thumbSem != nil {
thumbCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if err := thumbSem.Acquire(thumbCtx); err != nil {
return
}
defer thumbSem.Release()
}
var img []byte
provider := detectPreviewProvider(job, out)
previewState := previewStateForJob(job)
imageURL := strings.TrimSpace(previewImageURLForJob(job))
if provider == "chaturbate" {
if imageURL != "" {
if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
} else {
if previewState == "public" && imageURL != "" {
if b, err := downloadPreviewImageJPG(ctx, imageURL, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && previewM3U8 != "" {
if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 {
img = b
}
}
if len(img) == 0 && out != "" {
if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 {
img = b
}
}
}
if len(img) == 0 {
return
}
job.previewMu.Lock()
job.previewJPG = append([]byte(nil), img...)
job.previewJPGAt = time.Now()
job.previewMu.Unlock()
}
func startLiveThumbJPGLoop(ctx context.Context, job *RecordJob) {
jobsMu.Lock()
if job.LiveThumbStarted {
jobsMu.Unlock()
return
}
job.LiveThumbStarted = true
jobsMu.Unlock()
go func() {
updateLiveThumbJPGOnce(ctx, job)
for {
select {
case <-ctx.Done():
return
case <-time.After(20 * time.Second):
jobsMu.RLock()
st := job.Status
jobsMu.RUnlock()
if st != JobRunning {
return
}
updateLiveThumbJPGOnce(ctx, job)
}
}
}()
}
func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) {
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
if sec < 0 {
sec = 0
}
img, err := extractFrameAtTimeJPG(outPath, sec)
if err == nil && len(img) > 0 {
servePreviewJPGBytes(w, img)
return
}
}
}
thumbPath := filepath.Join(assetDir, "preview.jpg")
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPGFile(w, r, thumbPath)
return
}
// Wenn noch kein generiertes preview.jpg existiert, Provider-Fallbackbild liefern
if serveFallbackPreviewImage(w, r, nil, outPath) {
return
}
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// ✅ Immer letztes Frame bevorzugen
img, err := extractLastFrameJPG(outPath)
if err != nil || len(img) == 0 {
// Fallback: kurz vor Ende, falls Duration verfügbar
if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 {
t := dur - 0.25
if t < 0 {
t = 0
}
img, err = extractFrameAtTimeJPG(outPath, t)
}
// Letzter Fallback: erstes Frame
if err != nil || len(img) == 0 {
img, err = extractFirstFrameJPGScaled(outPath, 720, 75)
if err != nil || len(img) == 0 {
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
return
}
}
}
_ = atomicWriteFile(thumbPath, img)
servePreviewJPGBytes(w, img)
}