// backend\startup_leftovers.go package main import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/google/uuid" ) const ( startupLeftoverMinAge = 60 * time.Second // .muxing.ts sind temporäre Zwischenstände. // Nur alte Dateien löschen, damit ein sehr frischer/noch schreibender Prozess // nicht versehentlich getroffen wird. startupMuxingTempMinAge = 10 * time.Minute postworkLeftoverInitialDelay = 8 * time.Second postworkLeftoverScanInterval = 1 * time.Hour ) func startPostworkLeftoverScanOnStartup() { go func() { // Kurz warten, bis Settings, Queues und UI/SSE bereit sind. time.Sleep(postworkLeftoverInitialDelay) runPostworkLeftoverScan := func(reason string) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() jobID := newTaskID(postworkLeftoverTaskPrefix(reason)) createCleanupJob(jobID, "Wartet…", cancel) updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Queued = false st.Running = true st.StartedAt = time.Now() st.Done = 0 st.Total = 2 st.CurrentFile = "" st.Text = postworkLeftoverRunningText(reason) st.Error = "" st.FinishedAt = nil }) removedMuxing, skippedMuxing, err := cleanupStaleMuxingTempFilesFromRecordDir(ctx) if err != nil { finishCleanupJob(jobID, "", err) return } updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Done = 1 st.Total = 2 st.Text = "Suche offene Postwork-Dateien…" st.CurrentFile = "" }) queued, skipped, err := enqueuePostworkLeftoversFromRecordDir(ctx) if err != nil { finishCleanupJob(jobID, "", err) return } summary := postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing) if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 { appLogln( "🧩 [startup-postwork] leftover scan fertig:", "reason=", reason, "queued=", queued, "skipped=", skipped, "removedMuxing=", removedMuxing, "skippedMuxing=", skippedMuxing, ) } updateCleanupJobState(jobID, func(st *CleanupTaskState) { st.Done = 2 st.Total = 2 st.Text = summary st.CurrentFile = "" }) finishCleanupJob(jobID, summary, nil) } runPostworkLeftoverScan("startup") ticker := time.NewTicker(postworkLeftoverScanInterval) defer ticker.Stop() for range ticker.C { runPostworkLeftoverScan("interval") } }() } func postworkLeftoverTaskPrefix(reason string) string { switch strings.TrimSpace(strings.ToLower(reason)) { case "startup": return "cleanup-leftovers-startup" case "interval": return "cleanup-leftovers-hourly" default: return "cleanup-leftovers" } } func postworkLeftoverRunningText(reason string) string { switch strings.TrimSpace(strings.ToLower(reason)) { case "startup": return "Startup-Cleanup: prüfe Record-Reste…" case "interval": return "Stündlicher Cleanup: prüfe Record-Reste…" default: return "Prüfe Record-Reste…" } } func sleepContext(ctx context.Context, d time.Duration) error { if d <= 0 { return ctx.Err() } timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing int) string { parts := make([]string, 0, 4) if queued > 0 { parts = append(parts, fmt.Sprintf("Postwork eingereiht: %d", queued)) } if removedMuxing > 0 { parts = append(parts, fmt.Sprintf("Muxing-Temps gelöscht: %d", removedMuxing)) } if skipped > 0 { parts = append(parts, fmt.Sprintf("Postwork übersprungen: %d", skipped)) } if skippedMuxing > 0 { parts = append(parts, fmt.Sprintf("Muxing-Temps übersprungen: %d", skippedMuxing)) } if len(parts) == 0 { return "Keine Record-Reste gefunden." } return strings.Join(parts, " | ") } func cleanupStaleMuxingTempFilesFromRecordDir(ctx context.Context) (removed int, skipped int, err error) { if err := ctx.Err(); err != nil { return 0, 0, err } s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil || strings.TrimSpace(recordAbs) == "" { recordAbs = strings.TrimSpace(s.RecordDir) } recordAbs = strings.TrimSpace(recordAbs) if recordAbs == "" { appLogln("⚠️ [startup-postwork] recordDir ist leer") return 0, 0, nil } entries, err := os.ReadDir(recordAbs) if err != nil { appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) return 0, 0, nil } now := time.Now() for _, e := range entries { if err := ctx.Err(); err != nil { return removed, skipped, err } if e == nil || e.IsDir() { continue } name := strings.TrimSpace(e.Name()) if name == "" { continue } if !isMuxingTempTSName(name) { continue } path := filepath.Join(recordAbs, name) fi, err := os.Stat(path) if err != nil || fi == nil || fi.IsDir() { skipped++ continue } if fi.Size() <= 0 { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { skipped++ appLogln("⚠️ [startup-postwork] .muxing.ts leer, aber löschen fehlgeschlagen:", name, err) continue } removed++ appLogln("🗑️ [startup-postwork] leere .muxing.ts gelöscht:", name) continue } age := now.Sub(fi.ModTime()) if age < startupMuxingTempMinAge { skipped++ appLogln( "⏭️ [startup-postwork] .muxing.ts zu frisch:", name, "age=", age.Round(time.Second), "min=", startupMuxingTempMinAge, ) continue } stable, err := singleFileStable(ctx, path) if err != nil { return removed, skipped, err } if !stable { skipped++ appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name) continue } // Falls für diesen temporären Namen ein Audio-Sidecar existiert, direkt mit entfernen. removedAudioSidecars := removePostworkAudioSidecarsForOutput(path) if err := os.Remove(path); err != nil && !os.IsNotExist(err) { skipped++ appLogln("⚠️ [startup-postwork] .muxing.ts löschen fehlgeschlagen:", name, err) continue } removed++ appLogln( "🗑️ [startup-postwork] alte .muxing.ts gelöscht:", name, "audioSidecars=", len(removedAudioSidecars), ) } return removed, skipped, nil } func isMuxingTempTSName(name string) bool { name = strings.TrimSpace(filepath.Base(name)) if name == "" { return false } lowerName := strings.ToLower(name) return strings.HasSuffix(lowerName, ".muxing.ts") } func singleFileStable(ctx context.Context, path string) (bool, error) { path = strings.TrimSpace(path) if path == "" { return false, nil } sizeOf := func(p string) (int64, bool) { fi, err := os.Stat(p) if err != nil || fi == nil || fi.IsDir() { return 0, false } return fi.Size(), true } size1, ok1 := sizeOf(path) if !ok1 { return false, nil } if err := sleepContext(ctx, 1500*time.Millisecond); err != nil { return false, err } size2, ok2 := sizeOf(path) if !ok2 { return false, nil } return size1 == size2, nil } func enqueuePostworkLeftoversFromRecordDir(ctx context.Context) (queued int, skipped int, err error) { if err := ctx.Err(); err != nil { return 0, 0, err } s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil || strings.TrimSpace(recordAbs) == "" { recordAbs = strings.TrimSpace(s.RecordDir) } recordAbs = strings.TrimSpace(recordAbs) if recordAbs == "" { appLogln("⚠️ [startup-postwork] recordDir ist leer") return 0, 0, nil } entries, err := os.ReadDir(recordAbs) if err != nil { appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) return 0, 0, nil } now := time.Now() skip := func(name string, reason string, extra ...any) { skipped++ args := []any{ "⏭️ [startup-postwork] skip:", name, "reason:", reason, } args = append(args, extra...) appLogln(args...) } for _, e := range entries { if err := ctx.Err(); err != nil { return queued, skipped, err } if e == nil || e.IsDir() { continue } name := strings.TrimSpace(e.Name()) if name == "" { continue } if strings.HasPrefix(name, ".") { continue } if !strings.EqualFold(filepath.Ext(name), ".ts") { continue } tsPath := filepath.Join(recordAbs, name) tsInfo, err := os.Stat(tsPath) if err != nil { skip(name, "ts stat failed", err) continue } if tsInfo == nil || tsInfo.IsDir() { skip(name, "ts invalid") continue } if tsInfo.Size() <= 0 { skip(name, "ts empty") continue } tsAge := now.Sub(tsInfo.ModTime()) if tsAge < startupLeftoverMinAge { skip( name, "ts too fresh", "age=", tsAge.Round(time.Second), "min=", startupLeftoverMinAge, ) continue } audioPath, ok := findPostworkAudioSidecarForTS(tsPath) if !ok { skip(name, "matching audio sidecar not found") continue } audioInfo, err := os.Stat(audioPath) if err != nil { skip(name, "audio stat failed", filepath.Base(audioPath), err) continue } if audioInfo == nil || audioInfo.IsDir() { skip(name, "audio invalid", filepath.Base(audioPath)) continue } if audioInfo.Size() <= 0 { skip(name, "audio empty", filepath.Base(audioPath)) continue } audioAge := now.Sub(audioInfo.ModTime()) if audioAge < startupLeftoverMinAge { skip( name, "audio too fresh", filepath.Base(audioPath), "age=", audioAge.Round(time.Second), "min=", startupLeftoverMinAge, ) continue } if postworkLeftoverAlreadyTracked(tsPath) { skip(name, "already tracked as job") continue } if postworkLeftoverDoneMP4Exists(tsPath) { skip(name, "done mp4 already exists") continue } stable, err := postworkLeftoverFilesStable(ctx, tsPath, audioPath) if err != nil { return queued, skipped, err } if !stable { skip(name, "files not stable", filepath.Base(audioPath)) continue } if enqueuePostworkLeftover(tsPath, tsInfo) { queued++ } else { skip(name, "enqueue failed") } } return queued, skipped, nil } func postworkLeftoverAlreadyTracked(tsPath string) bool { tsPath = filepath.Clean(strings.TrimSpace(tsPath)) if tsPath == "" { return false } jobsMu.RLock() defer jobsMu.RUnlock() for _, j := range jobs { if j == nil { continue } out := filepath.Clean(strings.TrimSpace(j.Output)) if out != "" && strings.EqualFold(out, tsPath) { return true } } return false } func postworkLeftoverDoneMP4Exists(tsPath string) bool { tsPath = strings.TrimSpace(tsPath) if tsPath == "" { return false } s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { doneAbs = strings.TrimSpace(s.DoneDir) } doneAbs = strings.TrimSpace(doneAbs) if doneAbs == "" { return false } base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)) + ".mp4" donePath := filepath.Join(doneAbs, base) fi, err := os.Stat(donePath) return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 } func postworkLeftoverFilesStable(ctx context.Context, tsPath string, audioPath string) (bool, error) { tsPath = strings.TrimSpace(tsPath) audioPath = strings.TrimSpace(audioPath) if tsPath == "" || audioPath == "" { return false, nil } sizeOf := func(p string) (int64, bool) { fi, err := os.Stat(p) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { return 0, false } return fi.Size(), true } ts1, ok1 := sizeOf(tsPath) audio1, ok2 := sizeOf(audioPath) if !ok1 || !ok2 { return false, nil } if err := sleepContext(ctx, 1500*time.Millisecond); err != nil { return false, err } ts2, ok1 := sizeOf(tsPath) audio2, ok2 := sizeOf(audioPath) if !ok1 || !ok2 { return false, nil } return ts1 == ts2 && audio1 == audio2, nil } func enqueuePostworkLeftover(tsPath string, tsInfo os.FileInfo) bool { tsPath = strings.TrimSpace(tsPath) if tsPath == "" || tsInfo == nil { return false } now := time.Now() startedAt := tsInfo.ModTime() if startedAt.IsZero() { startedAt = now } job := &RecordJob{ ID: uuid.NewString(), SourceURL: "", Status: JobFinished, StartedAt: startedAt, StartedAtMs: startedAt.UnixMilli(), EndedAt: &now, EndedAtMs: now.UnixMilli(), Output: tsPath, Phase: "postwork", Progress: 0, SizeBytes: tsInfo.Size(), Hidden: false, } jobsMu.Lock() jobs[job.ID] = job jobsMu.Unlock() appLogln( "🧩 [startup-postwork] queued leftover:", filepath.Base(tsPath), ) enqueuePostworkOrFail(job, tsPath, JobFinished) return true }