nsfwapp/backend/tasks_assets.go
2026-02-06 10:28:46 +01:00

365 lines
8.4 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
}
// ✅ Dauer zuerst aus meta.json, sonst 1× ffprobe & meta.json schreiben
durSec := 0.0
metaOK := false
if d, ok := readVideoMetaDuration(metaPath, vfi); ok {
durSec = d
metaOK = true
// meta ist valide (Duration ok), aber falls wir (irgendwoher) eine SourceURL hätten
// und sie in meta noch fehlt -> meta anreichern ohne ffprobe.
if strings.TrimSpace(sourceURL) != "" {
if u, ok := readVideoMetaSourceURL(metaPath, vfi); !ok || strings.TrimSpace(u) == "" {
_ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL)
}
}
} else {
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, it.path)
cancel()
if derr == nil && d > 0 {
durSec = d
// ✅ HIER: nicht writeVideoMeta(metaPath, fi, dur, sourceURL) !!
// sondern Duration-only writer nutzen
_ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL)
metaOK = true
}
}
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)
}