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

457 lines
11 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"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL)
if url == "" {
return nil, errors.New("url fehlt")
}
// Duplicate-running guard (identische URL)
jobsMu.Lock()
for _, j := range jobs {
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
// ✅ Wenn ein versteckter Auto-Check-Job läuft und der User manuell startet -> sofort sichtbar machen
if j.Hidden && !req.Hidden {
j.Hidden = false
jobsMu.Unlock()
notifyJobsChanged()
return j, nil
}
jobsMu.Unlock()
return j, nil
}
}
// ✅ Timestamp + Output schon hier setzen, damit UI sofort Model/Filename/Details hat
startedAt := time.Now()
provider := detectProvider(url)
// best-effort Username aus URL
username := ""
switch provider {
case "chaturbate":
username = extractUsername(url)
case "mfc":
username = extractMFCUsername(url)
}
if strings.TrimSpace(username) == "" {
username = "unknown"
}
// Dateiname (konsistent zu runJob: gleicher Timestamp)
filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05"))
// best-effort: absoluter RecordDir (fallback auf Settings-Wert)
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,
Output: outPath, // ✅ sofort befüllt
Hidden: req.Hidden, // ✅ NEU
cancel: cancel,
}
jobs[jobID] = job
jobsMu.Unlock()
// ✅ NEU: Hidden-Jobs nicht sofort ins UI broadcasten
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
// ✅ nutze den Timestamp vom Job (damit Start/Output konsistent sind)
now := job.StartedAt
if now.IsZero() {
now = time.Now()
}
// ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ----
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"))
// ✅ wenn Output schon beim Start gesetzt wurde, nutze ihn (falls absolut)
jobsMu.Lock()
existingOut := strings.TrimSpace(job.Output)
jobsMu.Unlock()
outPath := existingOut
if outPath == "" || !filepath.IsAbs(outPath) {
outPath = filepath.Join(recordDirAbs, filename)
}
// Output nur aktualisieren, wenn es sich ändert
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")
}
// ---- Recording fertig: EndedAt/Error setzen ----
end := time.Now()
// Zielstatus bestimmen (finaler Status wird erst NACH Postwork gesetzt!)
target := JobFinished
var errText string
if err != nil {
if errors.Is(err, context.Canceled) {
target = JobStopped
} else {
target = JobFailed
errText = err.Error()
}
}
// direkt nach provider record endet (egal ob err != nil oder nil)
stopPreview(job)
// EndedAt + Error speichern (kurz locken)
jobsMu.Lock()
job.EndedAt = &end
if errText != "" {
job.Error = errText
}
out := strings.TrimSpace(job.Output)
jobsMu.Unlock()
notifyJobsChanged()
// Falls Output fehlt (z.B. provider error), direkt final status setzen
if out == "" {
jobsMu.Lock()
job.Status = target
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
return
}
// ✅ Postwork: remux/move/ffprobe/assets begrenzen -> in Queue
postOut := out
postTarget := target
postKey := "postwork:" + job.ID
// ✅ WICHTIG:
// - Status noch NICHT auf JobStopped/JobFinished setzen, sonst verschwindet er aus der Downloads-Tabelle.
// - Stattdessen Phase "postwork" + Progress hochsetzen (monoton).
// - Zusätzlich: PostWorkKey setzen + initialen Queue-Status ins Job-JSON hängen.
jobsMu.Lock()
job.Phase = "postwork"
if job.Progress < 70 {
job.Progress = 70
}
job.PostWorkKey = postKey
// initialer Status (meist "missing", bis Enqueue done ist wir updaten direkt danach nochmal)
{
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 {
// beim Start: Queue-Status refresh (sollte jetzt "running" werden)
{
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
job.PostWork = &st
// optional: wenn du "queued" Progress optisch unterscheiden willst
if job.Phase == "postwork" && job.Progress < 71 {
job.Progress = 71
}
jobsMu.Unlock()
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
}
// Helper: Progress nur nach oben (gegen "rückwärts")
setPhase := func(phase string, pct int) {
jobsMu.Lock()
if pct < job.Progress {
pct = job.Progress
}
job.Phase = phase
job.Progress = pct
// Queue-Status auch bei Phase-Wechsel aktuell halten (nice für UI)
st := postWorkQ.StatusForKey(postKey)
job.PostWork = &st
jobsMu.Unlock()
notifyJobsChanged()
}
// 1) Remux (nur wenn TS)
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 (best-effort)
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()
// ✅ erst JETZT ist done wirklich betroffen
notifyDoneChanged()
}
// 3) Optional: kleine Downloads automatisch löschen
setPhase("postwork", 82)
if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
notifyJobsChanged()
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
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)))
if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) {
removeGeneratedForID(id)
if doneAbs, rerr := resolvePathRelativeToApp(getSettings().DoneDir); rerr == nil && strings.TrimSpace(doneAbs) != "" {
_ = os.RemoveAll(filepath.Join(doneAbs, "preview", id))
_ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id))
}
purgeDurationCacheForPath(out)
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
fmt.Println("🧹 auto-deleted:", base, "size:", formatBytesSI(fi.Size()))
return nil
} else {
fmt.Println("⚠️ auto-delete failed:", derr)
}
}
}
}
// 4) Dauer (ffprobe)
setPhase("ffprobe", 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()
}
// 5) 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()
}
}
// 6) Assets (thumbs.jpg + preview.mp4)
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 := ensureAssetsForVideoWithProgress(out, job.SourceURL, update); err != nil {
fmt.Println("⚠️ ensureAssetsForVideo:", err)
}
setPhase("assets", assetsEnd)
// 7) Finalize: JETZT finalen Status setzen (damit er erst dann aus Downloads verschwindet)
jobsMu.Lock()
job.Status = postTarget
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
return nil
},
})
if okQueued {
// ✅ direkt nach erfolgreichem Enqueue nochmal Status holen (nun "queued" + Position möglich)
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
job.PostWork = &st
jobsMu.Unlock()
notifyJobsChanged()
} else {
// Queue voll -> Fallback: finalisieren
jobsMu.Lock()
job.Status = postTarget
job.Phase = ""
job.Progress = 100
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
}
return
}