// backend\startup_leftovers.go package main import ( "os" "path/filepath" "strings" "time" "github.com/google/uuid" ) const startupLeftoverMinAge = 60 * time.Second func startPostworkLeftoverScanOnStartup() { go func() { // Kurz warten, bis Settings, Queues und UI/SSE bereit sind. time.Sleep(8 * time.Second) queued, skipped := enqueuePostworkLeftoversFromRecordDir() if queued > 0 || skipped > 0 { appLogln( "🧩 [startup-postwork] leftover scan fertig:", "queued=", queued, "skipped=", skipped, ) } }() } func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { 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 } entries, err := os.ReadDir(recordAbs) if err != nil { appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) return 0, 0 } 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 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 } if !postworkLeftoverFilesStable(tsPath, audioPath) { skip(name, "files not stable", filepath.Base(audioPath)) continue } if enqueuePostworkLeftover(tsPath, tsInfo) { queued++ } else { skip(name, "enqueue failed") } } return queued, skipped } 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(tsPath string, audioPath string) bool { tsPath = strings.TrimSpace(tsPath) audioPath = strings.TrimSpace(audioPath) if tsPath == "" || audioPath == "" { return false } 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 } time.Sleep(1500 * time.Millisecond) ts2, ok1 := sizeOf(tsPath) audio2, ok2 := sizeOf(audioPath) if !ok1 || !ok2 { return false } return ts1 == ts2 && audio1 == audio2 } 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 }