nsfwapp/backend/startup_leftovers.go
2026-06-01 09:16:21 +02:00

447 lines
9.2 KiB
Go

// backend\startup_leftovers.go
package main
import (
"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) {
removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir()
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
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,
)
}
}
runPostworkLeftoverScan("startup")
ticker := time.NewTicker(postworkLeftoverScanInterval)
defer ticker.Stop()
for range ticker.C {
runPostworkLeftoverScan("interval")
}
}()
}
func cleanupStaleMuxingTempFilesFromRecordDir() (removed 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()
for _, e := range entries {
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
}
if !singleFileStable(path) {
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
}
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(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
return false
}
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
}
time.Sleep(1500 * time.Millisecond)
size2, ok2 := sizeOf(path)
if !ok2 {
return false
}
return size1 == size2
}
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
}