582 lines
12 KiB
Go
582 lines
12 KiB
Go
// 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
|
|
}
|