1178 lines
26 KiB
Go
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>/preview.webp"
|
|
// - "C:\...\generated\meta\<id>\preview.webp"
|
|
// - "http(s)://host/generated/meta/<id>/preview.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>/preview.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")
|