nsfwapp/backend/recorder.go
2026-03-03 21:14:39 +01:00

670 lines
14 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, 5}
case "remuxing":
return rng{5, 65}
case "moving":
return rng{65, 75}
case "probe":
return rng{75, 80}
case "assets":
return rng{80, 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
}
if phaseLower == "postwork" && pct == 0 {
job.Progress = 0
return
}
mapped := pct
if inPostwork {
r := rangeFor(phaseLower)
if r.end >= r.start {
if pct >= r.start && pct <= r.end {
mapped = pct
} else {
width := float64(r.end - r.start)
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.webp")
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/webp")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "preview-sprite.webp", 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()
notifyJobsChanged()
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 {
notifyJobsChanged()
}
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)
notifyJobsChanged()
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()
notifyJobsChanged()
}
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()
notifyJobsChanged()
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()
notifyJobsChanged()
if out == "" {
jobsMu.Lock()
job.Status = target
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
return
}
// pre-queue auto delete (small)
{
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
if s.AutoDeleteSmallDownloads && minMB > 0 {
threshold := int64(minMB) * 1024 * 1024
if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
notifyJobsChanged()
if fi.Size() > 0 && fi.Size() < threshold {
base := filepath.Base(out)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) {
removeGeneratedForID(id)
purgeDurationCacheForPath(out)
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
if shouldLogRecordInfo(req) {
fmt.Println("🧹 auto-deleted (pre-queue):", base, "(size: "+formatBytesSI(fi.Size())+")")
}
return
} else {
fmt.Println("⚠️ auto-delete (pre-queue) 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()
notifyJobsChanged()
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)
notifyJobsChanged()
}
out := strings.TrimSpace(postOut)
if out == "" {
jobsMu.Lock()
job.Phase = ""
job.Progress = 100
job.Status = postTarget
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
return nil
}
setPhase := func(phase string, pct int) {
setJobProgress(job, phase, pct)
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
job.PostWork = &st
jobsMu.Unlock()
notifyJobsChanged()
}
// 1) Remux
if strings.EqualFold(filepath.Ext(out), ".ts") {
setPhase("remuxing", 72)
if newOut, err2 := maybeRemuxTSForJob(job, out); err2 == nil && strings.TrimSpace(newOut) != "" {
out = strings.TrimSpace(newOut)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
notifyJobsChanged()
}
}
// 2) Move to done
setPhase("moving", 78)
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" {
out = strings.TrimSpace(moved)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
}
// 3) Duration
setPhase("probe", 84)
{
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()
notifyJobsChanged()
}
cancel()
}
// 4) Video props
setPhase("probe", 86)
{
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()
notifyJobsChanged()
}
}
// 5) Assets with progress
const (
assetsStart = 86
assetsEnd = 99
)
setPhase("assets", assetsStart)
lastPct := -1
lastTick := time.Time{}
update := func(r float64) {
if r < 0 {
r = 0
}
if r > 1 {
r = 1
}
pct := assetsStart + int(math.Round(r*float64(assetsEnd-assetsStart)))
if pct < assetsStart {
pct = assetsStart
}
if pct > assetsEnd {
pct = assetsEnd
}
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", assetsEnd)
// Finalize
jobsMu.Lock()
job.Status = postTarget
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
return nil
},
})
if okQueued {
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
job.PostWork = &st
jobsMu.Unlock()
notifyJobsChanged()
} else {
jobsMu.Lock()
job.Status = postTarget
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
}
}