nsfwapp/backend/tasks_assets.go
2026-02-09 12:29:19 +01:00

382 lines
8.9 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.

package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// ---------------------------
// Tasks: Missing Assets erzeugen
// ---------------------------
type AssetsTaskState struct {
Running bool `json:"running"`
Total int `json:"total"`
Done int `json:"done"`
GeneratedThumbs int `json:"generatedThumbs"`
GeneratedPreviews int `json:"generatedPreviews"`
Skipped int `json:"skipped"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
}
var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState
var assetsTaskCancel context.CancelFunc
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
case http.MethodPost:
assetsTaskMu.Lock()
if assetsTaskState.Running {
st := assetsTaskState
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
}
// ✅ cancelbaren Context erzeugen
ctx, cancel := context.WithCancel(context.Background())
assetsTaskCancel = cancel
assetsTaskState = AssetsTaskState{
Running: true,
StartedAt: time.Now(),
}
st := assetsTaskState
assetsTaskMu.Unlock()
go runGenerateMissingAssets(ctx)
writeJSON(w, http.StatusOK, st)
return
case http.MethodDelete:
assetsTaskMu.Lock()
cancel := assetsTaskCancel
running := assetsTaskState.Running
assetsTaskMu.Unlock()
if !running || cancel == nil {
// nichts zu stoppen
w.WriteHeader(http.StatusNoContent)
return
}
cancel()
// optional: sofortiges Feedback in state.error
assetsTaskMu.Lock()
if assetsTaskState.Running {
assetsTaskState.Error = "abgebrochen"
}
st := assetsTaskState
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
default:
http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed)
return
}
}
func runGenerateMissingAssets(ctx context.Context) {
finishWithErr := func(err error) {
now := time.Now()
assetsTaskMu.Lock()
assetsTaskState.Running = false
assetsTaskState.FinishedAt = &now
if err != nil {
assetsTaskState.Error = err.Error()
}
assetsTaskMu.Unlock()
}
defer func() {
assetsTaskMu.Lock()
assetsTaskCancel = nil
assetsTaskMu.Unlock()
}()
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
return
}
type item struct {
name string
path string
}
// .trash niemals verarbeiten
isTrashPath := func(full string) bool {
p := strings.ToLower(strings.ReplaceAll(full, "\\", "/"))
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
}
seen := map[string]struct{}{}
items := make([]item, 0, 512)
addIfVideo := func(full string) {
if isTrashPath(full) {
return
}
name := filepath.Base(full)
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
return
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
return
}
// Dedupe
if _, ok := seen[full]; ok {
return
}
seen[full] = struct{}{}
items = append(items, item{name: name, path: full})
}
scanOneLevel := func(dir string) {
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
// .trash-Ordner nie scannen
if e.IsDir() && strings.EqualFold(e.Name(), ".trash") {
continue
}
full := filepath.Join(dir, e.Name())
if e.IsDir() {
sub, err := os.ReadDir(full)
if err != nil {
continue
}
for _, se := range sub {
if se.IsDir() {
continue
}
addIfVideo(filepath.Join(full, se.Name()))
}
continue
}
addIfVideo(full)
}
}
// ✅ done + done/<model>/ + done/keep + done/keep/<model>/
scanOneLevel(doneAbs)
scanOneLevel(filepath.Join(doneAbs, "keep"))
assetsTaskMu.Lock()
assetsTaskState.Total = len(items)
assetsTaskState.Done = 0
assetsTaskState.GeneratedThumbs = 0
assetsTaskState.GeneratedPreviews = 0
assetsTaskState.Skipped = 0
assetsTaskState.Error = ""
assetsTaskMu.Unlock()
for i, it := range items {
if err := ctx.Err(); err != nil {
finishWithErr(err)
return
}
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
assetDir, derr := ensureGeneratedDir(id)
if derr != nil {
assetsTaskMu.Lock()
assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
fmt.Println("⚠️ ensureGeneratedDir:", derr)
continue
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
previewPath := filepath.Join(assetDir, "preview.mp4")
metaPath := filepath.Join(assetDir, "meta.json")
thumbOK := func() bool {
fi, err := os.Stat(thumbPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
previewOK := func() bool {
fi, err := os.Stat(previewPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
// Datei-Info (für Meta-Validierung)
vfi, verr := os.Stat(it.path)
if verr != nil || vfi.IsDir() || vfi.Size() <= 0 {
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
// ✅ SourceURL best-effort: aus bestehender meta.json, wenn vorhanden/valide
sourceURL := ""
if u, ok := readVideoMetaSourceURL(metaPath, vfi); ok {
sourceURL = u
}
// ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet
durSec := 0.0
vw, vh := 0, 0
fps := 0.0
// Wir wollen nicht nur "Duration ok", sondern auch Props ok.
// Sonst wird später fälschlich "skipped" und Resolution bleibt für immer leer.
metaOK := false
// 1) Versuch: komplette Meta lesen (Duration + w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Fallback: Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, it.path)
cancel()
if derr == nil && d > 0 {
durSec = d
}
}
// 3) Wenn wir Duration haben, aber Props fehlen: einmal ffprobe für Props
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
// optional: Semaphore verwenden (du hast durSem global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
durSem.Release()
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
}
}
// 4) Jetzt voll schreiben (inkl. Resolution via formatResolution)
if durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, durSec, vw, vh, fps, sourceURL)
}
// Meta gilt nur als "OK", wenn Duration + Auflösung vorhanden ist
metaOK = durSec > 0 && vw > 0 && vh > 0
if thumbOK && previewOK && metaOK {
assetsTaskMu.Lock()
assetsTaskState.Skipped++
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
// ----------------
// Thumbs
// ----------------
if !thumbOK {
genCtx, cancel := context.WithTimeout(ctx, 45*time.Second)
if err := thumbSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return
}
cancel() // Timeout-Context freigeben, Semaphore bleibt gehalten
defer thumbSem.Release()
t := 0.0
if durSec > 0 {
t = durSec * 0.5
}
img, e1 := extractFrameAtTimeJPEG(it.path, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(it.path)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(it.path)
}
}
// Release wurde defert, aber wir wollen pro Iteration releasen:
thumbSem.Release()
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedThumbs++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}
// ----------------
// Preview
// ----------------
if !previewOK {
genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute)
if err := genSem.Acquire(genCtx); err != nil {
cancel()
finishWithErr(err)
return
}
err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18)
genSem.Release()
cancel()
if err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedPreviews++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ preview clips:", err)
}
}
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
}
finishWithErr(nil)
}