475 lines
12 KiB
Go
475 lines
12 KiB
Go
// 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)
|
||
setJobProgress(job, "recording", 1)
|
||
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
|
||
jobsMu.Unlock()
|
||
|
||
// optisches "queued" bumping
|
||
setJobProgress(job, "postwork", 71)
|
||
|
||
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 (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
|
||
}
|