382 lines
8.9 KiB
Go
382 lines
8.9 KiB
Go
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 defer’t, 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)
|
||
}
|