// 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, 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() } // ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können) jobsMu.Lock() job.Phase = "recording" if job.Progress < 1 { job.Progress = 1 } jobsMu.Unlock() 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") } // ---- 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 } // ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt” job.Phase = "postwork" // ✅ Progress darf ab jetzt nicht mehr runtergehen (mind. Einstieg in Postwork) if job.Progress < 70 { job.Progress = 70 } 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() 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" 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() 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 (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 }