nsfwapp/backend/preview_covers.go
2026-02-20 18:18:59 +01:00

1178 lines
26 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"image/png"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/basicfont"
"golang.org/x/image/math/fixed"
)
// --------------------------
// Covers: generated/covers/<category>.<ext>
// --------------------------
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 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()
// Max. verfügbare Breite für Text (ohne Padding/Margins)
maxTextW := (b.Dx() - 2*margin) - 2*padX
if maxTextW <= 0 {
return
}
// Text ggf. kürzen, damit er ins Badge passt
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)
// harte Schranke gegen Extremfälle
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 == "" {
// notfalls nur Ellipsis
label = ellipsis
} else {
label = best
}
}
// Textmetriken
d := &font.Drawer{Face: face}
textW := d.MeasureString(label).Ceil()
textH := face.Metrics().Height.Ceil()
ascent := face.Metrics().Ascent.Ceil()
// Badge-Box (unten links)
x0 := b.Min.X + margin
y1 := b.Max.Y - margin
y0 := y1 - (textH + 2*padY)
x1 := x0 + (textW + 2*padX)
// Clamp nach rechts (falls Bild sehr schmal)
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
}
}
// Clamp nach oben (falls Bild sehr niedrig)
minY0 := b.Min.Y + margin
if y0 < minY0 {
y0 = minY0
y1 = y0 + (textH + 2*padY)
if y1 > b.Max.Y-margin {
// zu wenig Platz insgesamt
return
}
}
rect := image.Rect(x0, y0, x1, y1)
// Background
bg := image.NewUniform(color.RGBA{0, 0, 0, 170})
draw.Draw(img, rect, bg, image.Point{}, draw.Over)
// Optional: dünner Rand für mehr Kontrast
border := image.NewUniform(color.RGBA{255, 255, 255, 35})
// top
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Min.Y+1), border, image.Point{}, draw.Over)
// bottom
draw.Draw(img, image.Rect(rect.Min.X, rect.Max.Y-1, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
// left
draw.Draw(img, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+1, rect.Max.Y), border, image.Point{}, draw.Over)
// right
draw.Draw(img, image.Rect(rect.Max.X-1, rect.Min.Y, rect.Max.X, rect.Max.Y), border, image.Point{}, draw.Over)
// Text baseline
tx := x0 + padX
ty := y0 + padY + ascent
// Mini-Schatten (Lesbarkeit)
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)
// Text
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
}
// ✅ Passe diese Struct/Methoden an dein echtes ModelStore-API an.
type coverModel struct {
Key string // z.B. model key/name
Tags string // raw tags (csv/newline/…)
}
func listModelsForCovers() ([]coverModel, error) {
if coverModelStore == nil {
return nil, fmt.Errorf("model store not set")
}
ms := coverModelStore.List() // ✅ existiert bei dir
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 "", fmt.Errorf("category empty")
}
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
models, err := listModelsForCovers()
if err != nil {
return "", err
}
// 1) Kandidaten-Models nach Tag filtern
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 "", fmt.Errorf("no model with tag")
}
// 2) Kandidaten mischen und nacheinander probieren
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
// 3) done dirs (einmalig auflösen)
s := getSettings()
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
if derr != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("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" || ext == ".ts"
}
// 4) Für jedes passende Model: Dateien sammeln, random wählen, Thumb prüfen
for _, m := range cands {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
modelKey := strings.TrimSpace(m.Key)
if modelKey == "" {
continue
}
// Kandidaten: done/<model>/ und done/keep/<model>/
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))]
// thumbs sicherstellen (best effort)
_ = 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 "", fmt.Errorf("no downloads/thumbs for category")
}
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 "", fmt.Errorf("covers root ist leer")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
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]) // 16 hex chars reichen
}
if c == "" {
return "", fmt.Errorf("category fehlt")
}
c = strings.ReplaceAll(c, " ", "_")
c = coverKeyRe.ReplaceAllString(c, "_")
c = strings.Trim(c, "._-")
if c == "" {
return "", fmt.Errorf("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/webp"):
return ".webp", "image/webp"
case strings.Contains(ct, "image/gif"):
return ".gif", "image/gif"
}
// Magic bytes fallback
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 ".webp", "image/webp"
}
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 "", fmt.Errorf("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
}
exts := []string{".jpg", ".png", ".webp", ".gif"}
for _, ext := range exts {
p := filepath.Join(root, key+ext)
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, "", fmt.Errorf("src fehlt")
}
// ✅ 1) Lokaler Pfad: nur /generated/... erlauben
if strings.HasPrefix(rawURL, "/") {
clean := path.Clean(rawURL)
if !strings.HasPrefix(clean, "/generated/") {
return nil, "", fmt.Errorf("src ungültig")
}
if strings.Contains(clean, "..") {
return nil, "", fmt.Errorf("src ungültig")
}
rel := strings.TrimPrefix(clean, "/")
abs, err := resolvePathRelativeToApp(rel)
if err != nil || strings.TrimSpace(abs) == "" {
return nil, "", fmt.Errorf("src ungültig")
}
f, err := os.Open(abs)
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
defer f.Close()
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
if len(b) == 0 {
return nil, "", fmt.Errorf("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 ".webp":
ct = "image/webp"
case ".gif":
ct = "image/gif"
}
return b, ct, nil
}
// ✅ 2) Remote URL: nur http/https
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", fmt.Errorf("src ungültig")
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("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, "", fmt.Errorf("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, "", fmt.Errorf("download empty")
}
return b, resp.Header.Get("Content-Type"), nil
}
// irgendwo auf Package-Level
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
log.Printf("[cover] BATCH START")
}
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--
if coverBatchInflight <= 0 {
dur := time.Since(coverBatchStarted).Round(time.Millisecond)
log.Printf(
"[cover] BATCH END total=%d miss=%d forced=%d errors=%d noThumb=%d decodeFail=%d took=%s",
coverBatchTotal,
coverBatchMiss,
coverBatchForced,
coverBatchErrors,
coverBatchNoThumb,
coverBatchDecodeErr,
dur,
)
coverBatchInflight = 0
}
}
var (
reModelFromStem = regexp.MustCompile(`^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}`)
)
// stem ist z.B. "sigmasian_01_21_2026__07-28-13" oder ein Parent-Dir-Name
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 ""
}
// akzeptiert:
// - "/generated/meta/<id>/thumbs.webp"
// - "C:\...\generated\meta\<id>\thumbs.webp"
// - "http(s)://host/generated/meta/<id>/thumbs.webp"
// - (fallback) irgendeinen Dateinamen-Stem, der wie "<model>_MM_DD_YYYY__HH-MM-ss" aussieht
func inferModelFromThumbLike(srcOrPath string) string {
s := strings.TrimSpace(srcOrPath)
if s == "" {
return ""
}
// Windows -> slash
s = strings.ReplaceAll(s, `\`, `/`)
// Wenn URL: nimm nur den Path
if u, err := url.Parse(s); err == nil && u != nil && u.Scheme != "" && u.Host != "" {
s = u.Path
}
// Wenn es wie ".../<id>/thumbs.webp" aussieht: parent dir ist <id>
base := path.Base(s)
lb := strings.ToLower(base)
if strings.HasPrefix(lb, "thumbs.") {
id := path.Base(path.Dir(s))
return inferModelFromStem(id)
}
// Fallback: versuch Stem aus basename
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", ".webp", ".gif":
return true
default:
return false
}
}
for _, e := range entries {
name := e.Name()
lower := strings.ToLower(name)
// info.json
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
}
// cover image
ext := filepath.Ext(name)
if isCoverExt(ext) {
key := strings.TrimSuffix(name, ext)
v := ensure(key)
v.HasCover = true
}
}
// ✅ WICHTIG: Model nur ausgeben, wenn wirklich ein Cover-Bild existiert
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"
// Optional: model overlay
modelQ := strings.TrimSpace(r.URL.Query().Get("model"))
modelExplicit := modelQ != ""
model := modelQ
// Optional: src
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) // HIT | MISS | FORCED
w.Header().Set("X-Request-Id", reqID)
}
// 1) Cache hit: direkt von Disk (nur wenn nicht force)
if !force {
// Wenn model im Request/abgeleitet da ist: info.json muss existieren & gleich sein, sonst neu erzeugen
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 ".webp":
w.Header().Set("Content-Type", "image/webp")
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 ".webp":
w.Header().Set("Content-Type", "image/webp")
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)
}
}
// ✅ 3) Overlay + Re-Encode (bei dir aktuell ohne extra label-call; decode nur)
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)
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"
}
// 4) Vorherige Cover-Dateien entfernen
root, _ := coversRoot()
for _, e := range []string{".jpg", ".png", ".webp", ".gif"} {
_ = os.Remove(filepath.Join(root, key+e))
}
_ = os.Remove(filepath.Join(root, key+".info.json"))
// 5) Persistieren
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
}
// ✅ 6) info.json schreiben (best-effort)
writeCoverInfoBestEffort(key, coverInfo{
Category: category,
Model: strings.TrimSpace(model),
Src: strings.TrimSpace(usedSrc),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
})
// 7) Ausliefern
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)
}
// (Optional) falls du es irgendwo nutzen willst
var errCoverNotSupported = errors.New("cover not supported")