776 lines
17 KiB
Go
776 lines
17 KiB
Go
// backend/recorder.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ---------------- Progress mapping ----------------
|
|
|
|
func setJobProgress(job *RecordJob, phase string, pct int) {
|
|
phase = strings.TrimSpace(phase)
|
|
phaseLower := strings.ToLower(phase)
|
|
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
|
|
type rng struct{ start, end int }
|
|
|
|
rangeFor := func(ph string) rng {
|
|
switch ph {
|
|
case "postwork":
|
|
return rng{0, 8}
|
|
case "remuxing":
|
|
return rng{8, 38}
|
|
case "moving":
|
|
return rng{38, 54}
|
|
case "probe":
|
|
return rng{54, 70}
|
|
case "assets":
|
|
return rng{70, 88}
|
|
case "analyze":
|
|
return rng{88, 99}
|
|
default:
|
|
return rng{0, 100}
|
|
}
|
|
}
|
|
|
|
jobsMu.Lock()
|
|
defer jobsMu.Unlock()
|
|
|
|
inPostwork := job.EndedAt != nil || (strings.TrimSpace(job.Phase) != "" && strings.ToLower(strings.TrimSpace(job.Phase)) != "recording")
|
|
if inPostwork {
|
|
if phaseLower == "" || phaseLower == "recording" {
|
|
return
|
|
}
|
|
}
|
|
|
|
if phase != "" {
|
|
job.Phase = phase
|
|
}
|
|
|
|
// recording = direkter Prozentwert
|
|
if !inPostwork {
|
|
if pct < job.Progress {
|
|
pct = job.Progress
|
|
}
|
|
job.Progress = pct
|
|
return
|
|
}
|
|
|
|
// postwork-Phasen: pct ist IMMER lokal 0..100 innerhalb der Phase
|
|
r := rangeFor(phaseLower)
|
|
width := float64(r.end - r.start)
|
|
|
|
mapped := r.start
|
|
if width > 0 {
|
|
mapped = r.start + int(math.Round((float64(pct)/100.0)*width))
|
|
}
|
|
|
|
if mapped < r.start {
|
|
mapped = r.start
|
|
}
|
|
if mapped > r.end {
|
|
mapped = r.end
|
|
}
|
|
|
|
if mapped < job.Progress {
|
|
mapped = job.Progress
|
|
}
|
|
|
|
job.Progress = mapped
|
|
}
|
|
|
|
// ---------------- Preview scrubber ----------------
|
|
|
|
const defaultScrubberCount = 18
|
|
|
|
// /api/preview-scrubber/{index}?id=... (oder ?file=...)
|
|
func recordPreviewScrubberFrame(w http.ResponseWriter, r *http.Request) {
|
|
const prefix = "/api/preview-scrubber/"
|
|
if !strings.HasPrefix(r.URL.Path, prefix) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
idxPart := strings.Trim(strings.TrimPrefix(r.URL.Path, prefix), "/")
|
|
if idxPart == "" {
|
|
http.Error(w, "missing scrubber frame index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
idx, err := strconv.Atoi(idxPart)
|
|
if err != nil || idx < 0 {
|
|
http.Error(w, "invalid scrubber frame index", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
id := strings.TrimSpace(q.Get("id"))
|
|
file := strings.TrimSpace(q.Get("file"))
|
|
if id == "" && file == "" {
|
|
http.Error(w, "missing id or file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
durSec, err := lookupDurationForScrubber(r)
|
|
if err != nil || durSec <= 0 {
|
|
durSec = 60
|
|
}
|
|
|
|
count := defaultScrubberCount
|
|
if idx >= count {
|
|
idx = count - 1
|
|
}
|
|
if count < 1 {
|
|
count = 1
|
|
}
|
|
|
|
t := scrubberIndexToTime(idx, count, durSec)
|
|
|
|
targetQ := url.Values{}
|
|
if id != "" {
|
|
targetQ.Set("id", id)
|
|
}
|
|
if file != "" {
|
|
targetQ.Set("file", file)
|
|
}
|
|
targetQ.Set("t", fmt.Sprintf("%.3f", t))
|
|
|
|
w.Header().Set("Cache-Control", "private, max-age=300")
|
|
http.Redirect(w, r, "/api/preview?"+targetQ.Encode(), http.StatusFound)
|
|
}
|
|
|
|
// Gleichmäßig über die Videolänge sampeln (Mitte des Segments)
|
|
func scrubberIndexToTime(index, count int, durationSec float64) float64 {
|
|
if count <= 1 {
|
|
return 0.1
|
|
}
|
|
if durationSec <= 0 {
|
|
return 0.1
|
|
}
|
|
|
|
maxT := math.Max(0.1, durationSec-0.1)
|
|
ratio := (float64(index) + 0.5) / float64(count)
|
|
t := ratio * maxT
|
|
|
|
if t < 0.1 {
|
|
t = 0.1
|
|
}
|
|
if t > maxT {
|
|
t = maxT
|
|
}
|
|
return t
|
|
}
|
|
|
|
func lookupDurationForScrubber(r *http.Request) (float64, error) {
|
|
path, ok, _, _ := resolvePlayablePathFromQuery(r)
|
|
if !ok || strings.TrimSpace(path) == "" {
|
|
return 0, fmt.Errorf("unable to resolve file")
|
|
}
|
|
|
|
// best-effort meta
|
|
ensureMetaJSONForPlayback(r.Context(), path)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
sec, err := durationSecondsCached(ctx, path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return sec, nil
|
|
}
|
|
|
|
// ---------------- Preview sprite file handler ----------------
|
|
|
|
func recordPreviewSprite(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
http.Error(w, "Nur GET/HEAD", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
id := strings.TrimPrefix(r.URL.Path, "/api/record/preview-sprite/")
|
|
if id == r.URL.Path {
|
|
id = strings.TrimPrefix(r.URL.Path, "/api/preview-sprite/")
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
id = strings.Trim(id, "/")
|
|
|
|
if id == "" {
|
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var err error
|
|
id, err = sanitizeID(id)
|
|
if err != nil {
|
|
http.Error(w, "ungültige id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
dir, err := generatedDirForID(id)
|
|
if err != nil {
|
|
http.Error(w, "ungültige id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
spritePath := filepath.Join(dir, "preview-sprite.jpg")
|
|
|
|
fi, err := os.Stat(spritePath)
|
|
if err != nil || fi.IsDir() || fi.Size() <= 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
f, err := os.Open(spritePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
http.ServeContent(w, r, "preview-sprite.jpg", fi.ModTime(), f)
|
|
}
|
|
|
|
// ---------------- Start + run job ----------------
|
|
|
|
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|
url := strings.TrimSpace(req.URL)
|
|
if url == "" {
|
|
return nil, errors.New("url fehlt")
|
|
}
|
|
|
|
jobsMu.Lock()
|
|
for _, j := range jobs {
|
|
if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url {
|
|
if j.Hidden && !req.Hidden {
|
|
j.Hidden = false
|
|
jobsMu.Unlock()
|
|
|
|
publishJobUpsert(j)
|
|
return j, nil
|
|
}
|
|
|
|
jobsMu.Unlock()
|
|
return j, nil
|
|
}
|
|
}
|
|
|
|
startedAt := time.Now()
|
|
provider := detectProvider(url)
|
|
|
|
username := ""
|
|
switch provider {
|
|
case "chaturbate":
|
|
username = extractUsername(url)
|
|
case "mfc":
|
|
username = extractMFCUsername(url)
|
|
}
|
|
if strings.TrimSpace(username) == "" {
|
|
username = "unknown"
|
|
}
|
|
|
|
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
|
|
|
|
s := getSettings()
|
|
recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir)
|
|
recordDir := strings.TrimSpace(recordDirAbs)
|
|
if recordDir == "" {
|
|
recordDir = strings.TrimSpace(s.RecordDir)
|
|
}
|
|
outPath := filepath.Join(recordDir, filename)
|
|
|
|
jobID := uuid.NewString()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
job := &RecordJob{
|
|
ID: jobID,
|
|
SourceURL: url,
|
|
Status: JobRunning,
|
|
StartedAt: startedAt,
|
|
StartedAtMs: startedAt.UnixMilli(),
|
|
Output: outPath,
|
|
Hidden: req.Hidden,
|
|
cancel: cancel,
|
|
}
|
|
|
|
jobs[jobID] = job
|
|
jobsMu.Unlock()
|
|
|
|
if !job.Hidden {
|
|
publishJobUpsert(job)
|
|
}
|
|
|
|
go runJob(ctx, job, req)
|
|
return job, nil
|
|
}
|
|
|
|
func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|
hc := NewHTTPClient(req.UserAgent)
|
|
provider := detectProvider(req.URL)
|
|
|
|
var err error
|
|
|
|
now := job.StartedAt
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
|
|
if job.StartedAtMs == 0 {
|
|
base := job.StartedAt
|
|
if base.IsZero() {
|
|
base = time.Now()
|
|
jobsMu.Lock()
|
|
job.StartedAt = base
|
|
jobsMu.Unlock()
|
|
}
|
|
jobsMu.Lock()
|
|
job.StartedAtMs = base.UnixMilli()
|
|
jobsMu.Unlock()
|
|
}
|
|
|
|
setJobProgress(job, "recording", 0)
|
|
publishJobUpsert(job)
|
|
|
|
switch provider {
|
|
case "chaturbate":
|
|
if !hasChaturbateCookies(req.Cookie) {
|
|
err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich")
|
|
break
|
|
}
|
|
|
|
s := getSettings()
|
|
recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir)
|
|
if rerr != nil || strings.TrimSpace(recordDirAbs) == "" {
|
|
err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr)
|
|
break
|
|
}
|
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
|
|
|
username := extractUsername(req.URL)
|
|
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
|
|
|
jobsMu.Lock()
|
|
existingOut := strings.TrimSpace(job.Output)
|
|
jobsMu.Unlock()
|
|
|
|
outPath := existingOut
|
|
if outPath == "" || !filepath.IsAbs(outPath) {
|
|
outPath = filepath.Join(recordDirAbs, filename)
|
|
}
|
|
|
|
if strings.TrimSpace(existingOut) != strings.TrimSpace(outPath) {
|
|
jobsMu.Lock()
|
|
job.Output = outPath
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
}
|
|
|
|
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
|
|
|
|
case "mfc":
|
|
s := getSettings()
|
|
recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir)
|
|
if rerr != nil || strings.TrimSpace(recordDirAbs) == "" {
|
|
err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr)
|
|
break
|
|
}
|
|
_ = os.MkdirAll(recordDirAbs, 0o755)
|
|
|
|
username := extractMFCUsername(req.URL)
|
|
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
|
|
outPath := filepath.Join(recordDirAbs, filename)
|
|
|
|
jobsMu.Lock()
|
|
job.Output = outPath
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
|
|
err = RecordStreamMFC(ctx, hc, username, outPath, job)
|
|
|
|
default:
|
|
err = errors.New("unsupported provider")
|
|
}
|
|
|
|
if err != nil && shouldLogRecordError(err, provider, req) {
|
|
fmt.Println("❌ [record]", provider, job.SourceURL, "->", err)
|
|
}
|
|
|
|
end := time.Now()
|
|
|
|
target := JobFinished
|
|
var errText string
|
|
if err != nil {
|
|
if errors.Is(err, context.Canceled) {
|
|
target = JobStopped
|
|
} else {
|
|
target = JobFailed
|
|
errText = err.Error()
|
|
}
|
|
}
|
|
|
|
stopPreview(job)
|
|
|
|
jobsMu.Lock()
|
|
job.EndedAt = &end
|
|
job.EndedAtMs = end.UnixMilli()
|
|
if errText != "" {
|
|
job.Error = errText
|
|
}
|
|
job.Phase = "postwork"
|
|
out := strings.TrimSpace(job.Output)
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
|
|
if out == "" {
|
|
jobsMu.Lock()
|
|
job.Status = target
|
|
job.Phase = ""
|
|
job.Progress = 100
|
|
job.PostWorkKey = ""
|
|
job.PostWork = nil
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
return
|
|
}
|
|
|
|
// ✅ harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken
|
|
{
|
|
fi, serr := os.Stat(out)
|
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
_ = removeWithRetry(out)
|
|
purgeDurationCacheForPath(out)
|
|
|
|
jobsMu.Lock()
|
|
delete(jobs, job.ID)
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
|
|
if shouldLogRecordInfo(req) {
|
|
if serr != nil {
|
|
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(stat error:", serr, ")")
|
|
} else if fi == nil || fi.IsDir() {
|
|
fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(not a regular file)")
|
|
} else {
|
|
fmt.Println("🧹 removed empty output before postwork:", filepath.Base(out), "(0 bytes)")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// pre-queue gate: nur in die Nachbearbeitung, wenn Datei behalten werden soll
|
|
{
|
|
fi, serr := os.Stat(out)
|
|
if serr == nil && fi != nil && !fi.IsDir() {
|
|
jobsMu.Lock()
|
|
job.SizeBytes = fi.Size()
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
|
|
s := getSettings()
|
|
minMB := s.AutoDeleteSmallDownloadsBelowMB
|
|
|
|
// ✅ Wenn AutoDelete aktiv ist und Datei unter Schwellwert liegt:
|
|
// NICHT in die Postwork-Queue aufnehmen, sondern direkt löschen + return.
|
|
if s.AutoDeleteSmallDownloads && minMB > 0 {
|
|
threshold := int64(minMB) * 1024 * 1024
|
|
|
|
if fi.Size() > 0 && fi.Size() < threshold {
|
|
base := filepath.Base(out)
|
|
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
|
|
derr := removeWithRetry(out)
|
|
if derr == nil || os.IsNotExist(derr) {
|
|
removeGeneratedForID(id)
|
|
purgeDurationCacheForPath(out)
|
|
|
|
jobsMu.Lock()
|
|
delete(jobs, job.ID)
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
|
|
if shouldLogRecordInfo(req) {
|
|
fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")")
|
|
}
|
|
return
|
|
}
|
|
|
|
fmt.Println("⚠️ auto-delete before enqueue failed:", derr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// postwork queue
|
|
postOut := out
|
|
postTarget := target
|
|
postKey := "postwork:" + job.ID
|
|
|
|
jobsMu.Lock()
|
|
job.Phase = "postwork"
|
|
job.PostWorkKey = postKey
|
|
{
|
|
s := postWorkQ.StatusForKey(postKey)
|
|
job.PostWork = &s
|
|
}
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
|
|
okQueued := postWorkQ.Enqueue(PostWorkTask{
|
|
Key: postKey,
|
|
Added: time.Now(),
|
|
Run: func(ctx context.Context) error {
|
|
{
|
|
st := postWorkQ.StatusForKey(postKey)
|
|
jobsMu.Lock()
|
|
job.PostWork = &st
|
|
jobsMu.Unlock()
|
|
|
|
setJobProgress(job, "postwork", 0)
|
|
publishJobUpsert(job)
|
|
}
|
|
|
|
out := strings.TrimSpace(postOut)
|
|
if out == "" {
|
|
jobsMu.Lock()
|
|
job.Phase = ""
|
|
job.Progress = 100
|
|
job.Status = postTarget
|
|
job.PostWorkKey = ""
|
|
job.PostWork = nil
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
notifyDoneChanged()
|
|
return nil
|
|
}
|
|
|
|
setPhase := func(phase string, pct int) {
|
|
setJobProgress(job, phase, pct)
|
|
st := postWorkQ.StatusForKey(postKey)
|
|
jobsMu.Lock()
|
|
job.PostWork = &st
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
}
|
|
|
|
// 1) Remux
|
|
if strings.EqualFold(filepath.Ext(out), ".ts") {
|
|
setPhase("remuxing", 10)
|
|
if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" {
|
|
out = strings.TrimSpace(newOut)
|
|
jobsMu.Lock()
|
|
job.Output = out
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
}
|
|
}
|
|
|
|
// 2) Move to done
|
|
setPhase("moving", 10)
|
|
|
|
// ✅ auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben
|
|
{
|
|
fi, serr := os.Stat(out)
|
|
if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
_ = removeWithRetry(out)
|
|
purgeDurationCacheForPath(out)
|
|
|
|
jobsMu.Lock()
|
|
delete(jobs, job.ID)
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
|
|
if shouldLogRecordInfo(req) {
|
|
if serr != nil {
|
|
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")")
|
|
} else if fi == nil || fi.IsDir() {
|
|
fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(not a regular file)")
|
|
} else {
|
|
fmt.Println("🧹 removed empty post-remux output:", filepath.Base(out), "(0 bytes)")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" {
|
|
out = strings.TrimSpace(moved)
|
|
jobsMu.Lock()
|
|
job.Output = out
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
notifyDoneChanged()
|
|
}
|
|
|
|
// 3) Duration
|
|
setPhase("probe", 35)
|
|
{
|
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
|
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
|
|
jobsMu.Lock()
|
|
job.DurationSeconds = sec
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
}
|
|
cancel()
|
|
}
|
|
|
|
// 4) Video props
|
|
setPhase("probe", 75)
|
|
{
|
|
pctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
|
w, h, fps, perr := probeVideoProps(pctx, out)
|
|
cancel()
|
|
if perr == nil {
|
|
jobsMu.Lock()
|
|
job.VideoWidth = w
|
|
job.VideoHeight = h
|
|
job.FPS = fps
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
}
|
|
}
|
|
|
|
// 5) Assets with progress
|
|
setPhase("assets", 0)
|
|
|
|
lastPct := -1
|
|
lastTick := time.Time{}
|
|
|
|
update := func(r float64) {
|
|
if r < 0 {
|
|
r = 0
|
|
}
|
|
if r > 1 {
|
|
r = 1
|
|
}
|
|
|
|
pct := int(math.Round(r * 100))
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
|
|
if pct == lastPct {
|
|
return
|
|
}
|
|
if !lastTick.IsZero() && time.Since(lastTick) < 150*time.Millisecond {
|
|
return
|
|
}
|
|
|
|
lastPct = pct
|
|
lastTick = time.Now()
|
|
setPhase("assets", pct)
|
|
}
|
|
|
|
if _, err := ensureAssetsForVideoWithProgressCtx(ctx, out, job.SourceURL, update); err != nil {
|
|
fmt.Println("⚠️ ensureAssetsForVideo:", err)
|
|
}
|
|
setPhase("assets", 100)
|
|
|
|
// 6) AI Analyze -> meta.json.ai
|
|
setPhase("analyze", 5)
|
|
{
|
|
actx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
|
defer cancel()
|
|
|
|
id := assetIDFromVideoPath(out)
|
|
if strings.TrimSpace(id) == "" {
|
|
fmt.Println("⚠️ postwork analyze: keine asset id ableitbar")
|
|
} else {
|
|
ps := previewSpriteTruthForID(id)
|
|
if !ps.Exists {
|
|
fmt.Println("⚠️ postwork analyze: preview-sprite.jpg nicht gefunden")
|
|
} else {
|
|
durationSec, _ := durationSecondsForAnalyze(actx, out)
|
|
hits, aerr := analyzeVideoFromSprite(actx, out, "nsfw")
|
|
if aerr != nil {
|
|
fmt.Println("⚠️ postwork analyze:", aerr)
|
|
} else {
|
|
setPhase("analyze", 65)
|
|
|
|
segments := buildSegmentsFromAnalyzeHits(hits, durationSec)
|
|
|
|
ai := &aiAnalysisMeta{
|
|
Goal: "nsfw",
|
|
Mode: "sprite",
|
|
Hits: hits,
|
|
Segments: segments,
|
|
AnalyzedAtUnix: time.Now().Unix(),
|
|
}
|
|
|
|
if werr := writeVideoAIForFile(actx, out, job.SourceURL, ai); werr != nil {
|
|
fmt.Println("⚠️ writeVideoAIForFile:", werr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
setPhase("analyze", 100)
|
|
|
|
// Finalize
|
|
jobsMu.Lock()
|
|
job.Status = postTarget
|
|
job.Phase = ""
|
|
job.Progress = 100
|
|
job.PostWorkKey = ""
|
|
job.PostWork = nil
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
return nil
|
|
},
|
|
})
|
|
|
|
if okQueued {
|
|
st := postWorkQ.StatusForKey(postKey)
|
|
jobsMu.Lock()
|
|
job.PostWork = &st
|
|
jobsMu.Unlock()
|
|
publishJobUpsert(job)
|
|
} else {
|
|
jobsMu.Lock()
|
|
job.Status = postTarget
|
|
job.Phase = ""
|
|
job.Progress = 100
|
|
job.PostWorkKey = ""
|
|
job.PostWork = nil
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
}
|
|
}
|