nsfwapp/backend/record_start.go
2026-03-02 18:19:18 +01:00

490 lines
12 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.

// backend\record_start.go
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 {
// ✅ Nur blocken, solange wirklich noch aufgenommen wird.
// Sobald EndedAt gesetzt ist (Postwork/Queue läuft), darf ein neuer Download starten.
if j != nil && j.Status == JobRunning && j.EndedAt == nil && 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,
StartedAtMs: startedAt.UnixMilli(), // ✅ NEU
Output: outPath,
Hidden: req.Hidden,
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()
}
// ✅ falls StartedAtMs aus irgendeinem Grund leer ist
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()
}
// ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können)
setJobProgress(job, "recording", 0)
notifyJobsChanged()
// ---- 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")
}
if err != nil && shouldLogRecordError(err, provider, req) {
fmt.Println("❌ [record]", provider, job.SourceURL, "->", err)
}
// ---- 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
job.EndedAtMs = end.UnixMilli() // ✅ NEU
if errText != "" {
job.Error = errText
}
// ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt”
job.Phase = "postwork"
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
}
// ✅ NEU: Bevor Postwork queued wird -> kleine Downloads direkt löschen
// (spart Remux/Move/ffprobe/assets komplett)
{
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
if s.AutoDeleteSmallDownloads && minMB > 0 {
threshold := int64(minMB) * 1024 * 1024
// out ist i.d.R. absolut; Stat ist cheap
if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() {
// Size auch ins Job-JSON schreiben (nice fürs UI, selbst wenn wir danach löschen)
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)
// Job komplett entfernen (wie dein späterer Auto-Delete-Block)
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)
// wenn delete fehlschlägt -> normal weiter in Postwork
}
}
}
}
}
// ✅ 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"
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
jobsMu.Unlock()
// optisches "queued" bumping
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
}
// Helper: Progress nur nach oben (gegen "rückwärts")
setPhase := func(phase string, pct int) {
// Phase+Progress inkl. Mapping/Monotonie
setJobProgress(job, phase, pct)
// Queue-Status aktuell halten
st := postWorkQ.StatusForKey(postKey)
jobsMu.Lock()
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()
notifyDoneChanged()
}
// 3) Dauer (ffprobe)
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()
}
// 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 (preview.webp + 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
}