This commit is contained in:
Linrador 2026-06-19 08:46:21 +02:00
parent 1775cb5acd
commit 7a5b29c3e4

View File

@ -23,6 +23,19 @@ type cleanupCandidate struct {
size int64 size int64
} }
type recordCleanupFile struct {
path string
name string
lowName string
size int64
modTime time.Time
}
type recordCleanupDeleteCandidate struct {
file recordCleanupFile
reason string
}
type cleanupResp struct { type cleanupResp struct {
// Small / broken downloads cleanup // Small / broken downloads cleanup
ScannedFiles int `json:"scannedFiles"` ScannedFiles int `json:"scannedFiles"`
@ -435,14 +448,39 @@ func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
return false, "" return false, ""
} }
const cleanupRecordOrphanMinAge = 60 * time.Second const (
cleanupRecordOrphanMinAge = 60 * time.Second
cleanupRecordStableDelay = 1200 * time.Millisecond
)
func cleanupRecordFileIsActive(path string) bool { func cleanupRecordPathKey(path string) string {
path = filepath.Clean(strings.TrimSpace(path)) path = filepath.Clean(strings.TrimSpace(path))
if path == "" { if path == "" {
return ""
}
return strings.ToLower(path)
}
func cleanupRecordPathIsActive(path string, activePaths map[string]struct{}) bool {
key := cleanupRecordPathKey(path)
if key == "" || activePaths == nil {
return false return false
} }
_, ok := activePaths[key]
return ok
}
func snapshotCleanupRecordActivePaths() map[string]struct{} {
active := make(map[string]struct{}, 32)
add := func(path string) {
key := cleanupRecordPathKey(path)
if key != "" {
active[key] = struct{}{}
}
}
jobsMu.RLock() jobsMu.RLock()
defer jobsMu.RUnlock() defer jobsMu.RUnlock()
@ -452,132 +490,158 @@ func cleanupRecordFileIsActive(path string) bool {
} }
out := filepath.Clean(strings.TrimSpace(j.Output)) out := filepath.Clean(strings.TrimSpace(j.Output))
if out != "" && strings.EqualFold(out, path) { add(out)
return true
}
for _, sidecar := range postworkAudioSidecarCandidatesForOutput(out) { for _, sidecar := range postworkAudioSidecarCandidatesForOutput(out) {
if sidecar != "" && strings.EqualFold(filepath.Clean(sidecar), path) { add(sidecar)
return true
}
} }
} }
return active
}
func cleanupAudioSidecarNamesForTS(tsName string) []string {
tsName = strings.TrimSpace(filepath.Base(tsName))
if tsName == "" || strings.HasPrefix(tsName, ".") || !strings.EqualFold(filepath.Ext(tsName), ".ts") {
return nil
}
base := strings.TrimSuffix(tsName, filepath.Ext(tsName))
if strings.TrimSpace(base) == "" {
return nil
}
names := []string{
base + ".audio.m4s",
"." + base + ".audio.m4s",
}
if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" && !strings.EqualFold(canonical, base) {
names = append(names,
canonical+".audio.m4s",
"."+canonical+".audio.m4s",
)
}
return names
}
func cleanupHasAudioSidecarForTS(tsName string, filesByLowName map[string]recordCleanupFile) bool {
for _, sidecarName := range cleanupAudioSidecarNamesForTS(tsName) {
f, ok := filesByLowName[strings.ToLower(sidecarName)]
if ok && f.size > 0 {
return true
}
}
return false return false
} }
func cleanupFileStable(path string) bool { func cleanupWaitForStableRecordCandidates(
path = strings.TrimSpace(path) ctx context.Context,
if path == "" { candidates []recordCleanupDeleteCandidate,
return false activePaths map[string]struct{},
resp *cleanupResp,
) ([]recordCleanupDeleteCandidate, error) {
if len(candidates) == 0 {
return nil, nil
} }
fi1, err := os.Stat(path) now := time.Now()
if err != nil || fi1 == nil || fi1.IsDir() || fi1.Size() <= 0 { pendingStable := make([]recordCleanupDeleteCandidate, 0, len(candidates))
return false ready := make([]recordCleanupDeleteCandidate, 0, len(candidates))
for _, candidate := range candidates {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if now.Sub(candidate.file.modTime) < cleanupRecordOrphanMinAge {
resp.SkippedFiles++
continue
}
if cleanupRecordPathIsActive(candidate.file.path, activePaths) {
resp.SkippedFiles++
continue
}
if candidate.file.size <= 0 {
ready = append(ready, candidate)
continue
}
pendingStable = append(pendingStable, candidate)
} }
time.Sleep(1200 * time.Millisecond) if len(pendingStable) == 0 {
return ready, nil
fi2, err := os.Stat(path)
if err != nil || fi2 == nil || fi2.IsDir() || fi2.Size() <= 0 {
return false
} }
return fi1.Size() == fi2.Size() timer := time.NewTimer(cleanupRecordStableDelay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
for _, candidate := range pendingStable {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
fi, err := os.Stat(candidate.file.path)
if err != nil || fi == nil || fi.IsDir() {
resp.SkippedFiles++
continue
}
if fi.Size() != candidate.file.size {
resp.SkippedFiles++
continue
}
candidate.file.size = fi.Size()
candidate.file.modTime = fi.ModTime()
ready = append(ready, candidate)
}
return ready, nil
} }
func cleanupTSPathForAudioSidecar(audioPath string) string { func cleanupDeleteRecordOrphanCandidate(candidate recordCleanupDeleteCandidate, resp *cleanupResp) {
audioPath = strings.TrimSpace(audioPath)
if audioPath == "" {
return ""
}
dir := filepath.Dir(audioPath)
name := filepath.Base(audioPath)
lowName := strings.ToLower(name)
if !strings.HasSuffix(lowName, ".audio.m4s") {
return ""
}
base := name[:len(name)-len(".audio.m4s")]
base = strings.TrimPrefix(base, ".")
if strings.TrimSpace(base) == "" {
return ""
}
return filepath.Join(dir, base+".ts")
}
func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path string, resp *cleanupResp, reason string) {
if resp == nil { if resp == nil {
return return
} }
select { file := candidate.file
case <-ctx.Done():
return
default:
}
path = strings.TrimSpace(path) if err := removeWithRetry(file.path); err != nil && !os.IsNotExist(err) {
if path == "" {
return
}
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() {
return
}
size := fi.Size()
name := filepath.Base(path)
lowName := strings.ToLower(name)
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
resp.SkippedFiles++
return
}
if cleanupRecordFileIsActive(path) {
resp.SkippedFiles++
return
}
// 0-KB-Dateien sind stabil genug zum Löschen.
// Für >0 Bytes weiter die Stabilitätsprüfung behalten.
if size > 0 && !cleanupFileStable(path) {
resp.SkippedFiles++
return
}
// Fortschritt (Done/Total/CurrentFile) steuert der Aufrufer
// cleanupRecordDirOrphanAVFiles, damit die Progress-Bar sichtbar bleibt.
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
resp.ErrorCount++ resp.ErrorCount++
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", name, reason, err) appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", file.name, candidate.reason, err)
return return
} }
resp.DeletedFiles++ resp.DeletedFiles++
resp.DeletedRecordOrphans++ resp.DeletedRecordOrphans++
resp.DeletedBytes += size resp.DeletedBytes += file.size
resp.DeletedRecordOrphanBytes += size resp.DeletedRecordOrphanBytes += file.size
if strings.HasSuffix(lowName, ".muxing.ts") { if strings.HasSuffix(file.lowName, ".muxing.ts") {
resp.DeletedMuxingTemps++ resp.DeletedMuxingTemps++
} else if strings.HasSuffix(lowName, ".audio.m4s") { } else if strings.HasSuffix(file.lowName, ".audio.m4s") {
resp.DeletedAudioOrphans++ resp.DeletedAudioOrphans++
} else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") { } else if !strings.HasPrefix(file.name, ".") && strings.EqualFold(filepath.Ext(file.name), ".ts") {
resp.DeletedTSOrphans++ resp.DeletedTSOrphans++
} }
purgeDurationCacheForPath(path) purgeDurationCacheForPath(file.path)
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) base := strings.TrimSuffix(filepath.Base(file.path), filepath.Ext(file.path))
base = strings.TrimPrefix(base, ".") base = strings.TrimPrefix(base, ".")
base = strings.TrimSuffix(base, ".audio") base = strings.TrimSuffix(base, ".audio")
base = strings.TrimSuffix(base, ".muxing") base = strings.TrimSuffix(base, ".muxing")
@ -586,7 +650,7 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
removeGeneratedForID(stripHotPrefix(base)) removeGeneratedForID(stripHotPrefix(base))
} }
appLogln("🧹 cleanup deleted record orphan:", name, "reason:", reason, "size=", size) appLogln("🧹 cleanup deleted record orphan:", file.name, "reason:", candidate.reason, "size=", file.size)
} }
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error { func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
@ -600,41 +664,10 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
return nil return nil
} }
// Gesamtzahl der zu prüfenden Dateien für einen sichtbaren Fortschritt. files := make([]recordCleanupFile, 0, len(entries))
total := 0 filesByLowName := make(map[string]recordCleanupFile, len(entries))
for _, e := range entries {
if e == nil || e.IsDir() {
continue
}
if strings.TrimSpace(e.Name()) == "" {
continue
}
total++
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
if st.StartedAt.IsZero() {
st.StartedAt = time.Now()
}
st.Done = 0
st.Total = total
st.CurrentFile = ""
st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
})
processed := 0
for _, e := range entries { for _, e := range entries {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if e == nil || e.IsDir() { if e == nil || e.IsDir() {
continue continue
} }
@ -644,54 +677,153 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
continue continue
} }
processed++ info, err := e.Info()
updateCleanupJobState(jobID, func(st *CleanupTaskState) { if err != nil || info == nil || info.IsDir() {
st.Done = processed continue
st.Total = total }
st.CurrentFile = name
})
full := filepath.Join(recordAbs, name) file := recordCleanupFile{
low := strings.ToLower(name) path: filepath.Join(recordAbs, name),
name: name,
lowName: strings.ToLower(name),
size: info.Size(),
modTime: info.ModTime(),
}
files = append(files, file)
filesByLowName[file.lowName] = file
}
audioSidecarMatchedByLowName := make(map[string]bool, len(files))
for _, file := range files {
if strings.HasPrefix(file.name, ".") || !strings.EqualFold(filepath.Ext(file.name), ".ts") || file.size <= 0 {
continue
}
for _, sidecarName := range cleanupAudioSidecarNamesForTS(file.name) {
lowSidecarName := strings.ToLower(sidecarName)
if _, ok := filesByLowName[lowSidecarName]; ok {
audioSidecarMatchedByLowName[lowSidecarName] = true
}
}
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
if st.StartedAt.IsZero() {
st.StartedAt = time.Now()
}
st.Done = 0
st.Total = len(files)
st.CurrentFile = ""
st.Text = "Suche Record-Reste…"
st.Error = ""
st.FinishedAt = nil
})
activePaths := snapshotCleanupRecordActivePaths()
candidates := make([]recordCleanupDeleteCandidate, 0, 64)
lastProgressAt := time.Time{}
updateProgress := func(done, total int, currentFile, text string, force bool) {
now := time.Now()
if !force && now.Sub(lastProgressAt) < 250*time.Millisecond && done%100 != 0 {
return
}
lastProgressAt = now
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Done = done
st.Total = total
st.CurrentFile = strings.TrimSpace(currentFile)
if strings.TrimSpace(text) != "" {
st.Text = text
}
})
}
for i, file := range files {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
updateProgress(i+1, len(files), file.name, "Suche Record-Reste…", false)
// Fall 0: // Fall 0:
// alte .muxing.ts sind temporäre Remux-/Muxing-Reste. // alte .muxing.ts sind temporäre Remux-/Muxing-Reste.
if strings.HasSuffix(low, ".muxing.ts") { if strings.HasSuffix(file.lowName, ".muxing.ts") {
resp.ScannedFiles++ resp.ScannedFiles++
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "stale muxing temp") candidates = append(candidates, recordCleanupDeleteCandidate{
file: file,
reason: "stale muxing temp",
})
continue continue
} }
// Fall 1: // Fall 1:
// foo.ts existiert, aber kein passendes foo.audio.m4s/.foo.audio.m4s. // foo.ts existiert, aber kein passendes foo.audio.m4s/.foo.audio.m4s.
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") { if !strings.HasPrefix(file.name, ".") && strings.EqualFold(filepath.Ext(file.name), ".ts") {
resp.ScannedFiles++ resp.ScannedFiles++
if _, ok := findPostworkAudioSidecarForTS(full); ok { if cleanupHasAudioSidecarForTS(file.name, filesByLowName) {
continue continue
} }
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing audio sidecar") candidates = append(candidates, recordCleanupDeleteCandidate{
file: file,
reason: "missing audio sidecar",
})
continue continue
} }
// Fall 2: // Fall 2:
// foo.audio.m4s/.foo.audio.m4s existiert, aber foo.ts fehlt. // foo.audio.m4s/.foo.audio.m4s existiert, aber foo.ts fehlt.
if strings.HasSuffix(low, ".audio.m4s") { if strings.HasSuffix(file.lowName, ".audio.m4s") {
resp.ScannedFiles++ resp.ScannedFiles++
tsPath := cleanupTSPathForAudioSidecar(full) if audioSidecarMatchedByLowName[file.lowName] {
if strings.TrimSpace(tsPath) != "" { continue
if fi, err := os.Stat(tsPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
continue
}
} }
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "missing ts file") candidates = append(candidates, recordCleanupDeleteCandidate{
file: file,
reason: "missing ts file",
})
continue continue
} }
} }
updateProgress(len(files), len(files), "", "Prüfe stabile Record-Reste…", true)
stableCandidates, err := cleanupWaitForStableRecordCandidates(ctx, candidates, activePaths, resp)
if err != nil {
return err
}
updateProgress(0, len(stableCandidates), "", "Lösche Record-Reste…", true)
activePaths = snapshotCleanupRecordActivePaths()
for i, candidate := range stableCandidates {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
updateProgress(i+1, len(stableCandidates), candidate.file.name, "Lösche Record-Reste…", false)
if cleanupRecordPathIsActive(candidate.file.path, activePaths) {
resp.SkippedFiles++
continue
}
cleanupDeleteRecordOrphanCandidate(candidate, resp)
}
updateProgress(len(stableCandidates), len(stableCandidates), "", cleanupProgressText(*resp), true)
return nil return nil
} }