This commit is contained in:
Linrador 2026-05-24 23:14:48 +02:00
parent dccb6ff1ae
commit 831b51b4a7
7 changed files with 646 additions and 33 deletions

View File

@ -239,6 +239,39 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
out := strings.TrimSpace(job.Output)
wasVisible := !job.Hidden
// Sichtbare Jobs nicht endlos als "running" stehen lassen,
// wenn nach outputProbeMax keine Datei entstanden ist.
if wasVisible {
if recordCancel != nil {
recordCancel()
}
now := time.Now()
job.Status = JobFailed
job.EndedAt = &now
job.EndedAtMs = now.UnixMilli()
job.Phase = ""
job.Progress = 100
job.Error = "Kein Output nach Start ffmpeg konnte keine Datei schreiben"
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
if verboseLogs() {
appLogln("❌ [autostart] no output after probe; marking failed:", jobID)
}
_ = publishJobSnapshot(jobID)
if onNoOutput != nil {
onNoOutput()
}
return
}
jobsMu.Unlock()
if previewCancel != nil {
@ -290,12 +323,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
const retryCooldown = 25 * time.Second
// wie lange wir warten, ob eine echte Datei entsteht
const outputProbeMax = 20 * time.Second
const outputProbeMax = 60 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastTry := map[string]time.Time{}
// verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln
nonPublicSince := map[string]time.Time{}
scanTicker := time.NewTicker(scanInterval)
startTicker := time.NewTicker(startInterval)
defer scanTicker.Stop()
@ -573,6 +609,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
show := chaturbateShowFromSnapshot(showByUser, user)
if show != "private" && show != "hidden" && show != "away" {
delete(nonPublicSince, user)
continue
}
firstSeen, ok := nonPublicSince[user]
if !ok {
nonPublicSince[user] = now
continue
}
// Nicht sofort stoppen. CB/API kann kurz flackern.
if now.Sub(firstSeen) < 30*time.Second {
continue
}

View File

@ -195,6 +195,50 @@ func publishJobUpsertSnapshot(s jobEventSnapshot) {
}
}
func publishJobSnapshot(jobID string) bool {
jobsMu.RLock()
job := jobs[jobID]
if job == nil {
jobsMu.RUnlock()
return false
}
var endedAtCopy *time.Time
if job.EndedAt != nil {
t := *job.EndedAt
endedAtCopy = &t
}
var pw *PostWorkKeyStatus
if job.PostWork != nil {
tmp := *job.PostWork
pw = &tmp
}
snap := jobEventSnapshot{
ID: job.ID,
SourceURL: job.SourceURL,
Output: job.Output,
Status: job.Status,
StartedAt: job.StartedAt,
StartedAtMs: job.StartedAtMs,
EndedAt: endedAtCopy,
EndedAtMs: job.EndedAtMs,
Error: job.Error,
Phase: job.Phase,
Progress: job.Progress,
SizeBytes: job.SizeBytes,
DurationSeconds: job.DurationSeconds,
PreviewState: job.PreviewState,
PostWorkKey: job.PostWorkKey,
PostWork: pw,
}
jobsMu.RUnlock()
publishJobUpsertSnapshot(snap)
return true
}
func jobMatchesModelKey(j *RecordJob, modelKey string) bool {
if j == nil {
return false

View File

@ -188,7 +188,7 @@ func RecordStream(
updatePreviewFromStream(stream)
const maxPlaylistRefreshes = 12
const maxPlaylistRefreshes = 30
refreshes := 0
attempt := 0
useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts")
@ -208,12 +208,21 @@ func RecordStream(
mergeAfterRun = true
}
err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
err = recordHLSPlaylistSegments(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL)
if mergeAfterRun && fileExistsNonEmpty(attemptOut) {
if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil {
return appErrorf("retry-chunk merge failed: %w", aerr)
}
if job != nil {
if fi, statErr := os.Stat(outputPath); statErr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJobSnapshot(job.ID)
}
}
}
if err == nil {

View File

@ -405,6 +405,416 @@ func appendFileAndRemove(dstPath, srcPath string) error {
return nil
}
func hlsAttrValue(line string, key string) string {
line = strings.TrimSpace(line)
key = strings.TrimSpace(key)
if line == "" || key == "" {
return ""
}
needle := key + "="
idx := strings.Index(line, needle)
if idx < 0 {
return ""
}
rest := strings.TrimSpace(line[idx+len(needle):])
if rest == "" {
return ""
}
if strings.HasPrefix(rest, `"`) {
rest = rest[1:]
end := strings.IndexByte(rest, '"')
if end < 0 {
return strings.TrimSpace(rest)
}
return strings.TrimSpace(rest[:end])
}
if comma := strings.IndexByte(rest, ','); comma >= 0 {
rest = rest[:comma]
}
return strings.Trim(strings.TrimSpace(rest), `"`)
}
func hlsExtXMapURI(playlistText string) string {
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#EXT-X-MAP:") {
return hlsAttrValue(line, "URI")
}
}
return ""
}
func hlsPartURIsFromText(playlistText string) []string {
out := make([]string, 0, 16)
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "#EXT-X-PART:") {
continue
}
uri := hlsAttrValue(line, "URI")
if uri != "" {
out = append(out, uri)
}
}
return out
}
func hlsMediaSegmentURIsFromText(playlistText string) []string {
out := make([]string, 0, 16)
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out
}
func hlsRefreshDelay(playlistText string) time.Duration {
for _, line := range strings.Split(playlistText, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#EXT-X-PART-INF:") {
raw := hlsAttrValue(line, "PART-TARGET")
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
d := time.Duration(f * float64(time.Second))
if d < 500*time.Millisecond {
d = 500 * time.Millisecond
}
if d > 2*time.Second {
d = 2 * time.Second
}
return d
}
}
if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") {
raw := strings.TrimSpace(strings.TrimPrefix(line, "#EXT-X-TARGETDURATION:"))
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
d := time.Duration(f * float64(time.Second))
if d < 1*time.Second {
d = 1 * time.Second
}
if d > 3*time.Second {
d = 3 * time.Second
}
return d
}
}
}
return 1 * time.Second
}
func hlsResolveURI(baseRaw string, refRaw string) (string, error) {
baseRaw = strings.TrimSpace(baseRaw)
refRaw = strings.TrimSpace(refRaw)
if refRaw == "" {
return "", appErrorf("empty hls uri")
}
ref, err := url.Parse(refRaw)
if err != nil {
return "", err
}
if ref.IsAbs() {
return ref.String(), nil
}
base, err := url.Parse(baseRaw)
if err != nil {
return "", err
}
return base.ResolveReference(ref).String(), nil
}
func hlsGetBytesWithHeaders(
ctx context.Context,
rawURL string,
httpCookie string,
userAgent string,
referer string,
) ([]byte, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, appErrorf("empty hls url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
if strings.TrimSpace(userAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
}
if strings.TrimSpace(httpCookie) != "" {
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
}
if strings.TrimSpace(referer) != "" {
ref := strings.TrimSpace(referer)
req.Header.Set("Referer", ref)
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, appErrorf("HTTP %d: %s", resp.StatusCode, rawURL)
}
return io.ReadAll(resp.Body)
}
func hlsGetBytesWithHeadersRetry(
ctx context.Context,
rawURL string,
httpCookie string,
userAgent string,
referer string,
attempts int,
) ([]byte, error) {
if attempts < 1 {
attempts = 1
}
var lastErr error
for i := 1; i <= attempts; i++ {
b, err := hlsGetBytesWithHeaders(ctx, rawURL, httpCookie, userAgent, referer)
if err == nil {
return b, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
if i < attempts {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(600 * time.Millisecond):
}
}
}
return nil, lastErr
}
func writeStreamBytesAndPublish(f *os.File, job *RecordJob, b []byte) error {
if len(b) == 0 {
return nil
}
if _, err := f.Write(b); err != nil {
return err
}
if job != nil {
if fi, err := f.Stat(); err == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJobSnapshot(job.ID)
}
}
return nil
}
func streamLogPrefix(job *RecordJob, referer string, outFile string) string {
if s := strings.TrimSpace(hlsErrorModelName(job, referer)); s != "" {
return "[" + s + "]"
}
outFile = strings.TrimSpace(outFile)
if outFile != "" {
stem := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile))
stem = stripHotPrefix(stem)
if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" {
return "[" + s + "]"
}
if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" {
return "[" + s + "]"
}
}
return "[stream]"
}
func recordHLSPlaylistSegments(
ctx context.Context,
stream *selectedHLSStream,
outFile string,
job *RecordJob,
httpCookie string,
userAgent string,
referer string,
) error {
if stream == nil {
return errors.New("stream config nil")
}
logPrefix := streamLogPrefix(job, referer, outFile)
mediaURL := strings.TrimSpace(stream.VideoURL)
if mediaURL == "" {
return appErrorf("%s media playlist url empty", logPrefix)
}
outFile = strings.TrimSpace(outFile)
if outFile == "" {
return appErrorf("%s output path empty", logPrefix)
}
if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil {
return appErrorf("%s mkdir failed: %w", logPrefix, err)
}
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return appErrorf("%s open output failed: %w", logPrefix, err)
}
defer f.Close()
seen := map[string]struct{}{}
writtenMaps := map[string]struct{}{}
lastNewSegmentAt := time.Now()
if verboseLogs() {
appLogln("🎥", logPrefix, "recording media playlist:", mediaURL)
}
for {
if err := ctx.Err(); err != nil {
return err
}
body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer)
if err != nil {
return appErrorf("%s get playlist: %w", logPrefix, err)
}
playlistText := string(body)
if strings.Contains(playlistText, "#EXT-X-STREAM-INF") {
return appErrorf("%s expected media playlist, got master playlist", logPrefix)
}
segmentURIs := hlsMediaSegmentURIsFromText(playlistText)
if len(segmentURIs) == 0 {
segmentURIs = hlsPartURIsFromText(playlistText)
}
wroteAny := false
if len(segmentURIs) > 0 {
mapURI := hlsExtXMapURI(playlistText)
if mapURI != "" {
mapURL, err := hlsResolveURI(mediaURL, mapURI)
if err != nil {
return appErrorf("%s resolve map uri: %w", logPrefix, err)
}
if _, ok := writtenMaps[mapURL]; !ok {
initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3)
if err != nil {
appLogln("⚠️", logPrefix, "init segment failed:", err)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(hlsRefreshDelay(playlistText)):
}
continue
}
if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil {
return appErrorf("%s write init: %w", logPrefix, err)
}
writtenMaps[mapURL] = struct{}{}
wroteAny = true
}
}
}
for _, uri := range segmentURIs {
uri = strings.TrimSpace(uri)
if uri == "" {
continue
}
segmentURL, err := hlsResolveURI(mediaURL, uri)
if err != nil {
appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err)
continue
}
if _, ok := seen[segmentURL]; ok {
continue
}
segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3)
if err != nil {
appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err)
continue
}
if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil {
return appErrorf("%s write segment: %w", logPrefix, err)
}
seen[segmentURL] = struct{}{}
wroteAny = true
}
if wroteAny {
lastNewSegmentAt = time.Now()
} else if time.Since(lastNewSegmentAt) > 75*time.Second {
return io.EOF
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(hlsRefreshDelay(playlistText)):
}
}
}
func isExitStatus(err error, code int) bool {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
@ -429,12 +839,30 @@ func handleM3U8Mode(
videoURL := strings.TrimSpace(stream.VideoURL)
audioURL := strings.TrimSpace(stream.AudioURL)
u, err := url.Parse(videoURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return appErrorf("ungültige video URL: %q", videoURL)
isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com")
// Wichtig:
// Bei Chaturbate die MasterURL NICHT nochmal an ffmpeg geben.
// Die Master-Playlist wurde vorher schon in Go geöffnet/parst.
// Das erneute Öffnen der llhls.m3u8 führt bei dir häufig zu 403.
inputURL := videoURL
useSeparateAudioInput := false
if isChaturbate {
// Stabilitäts-Test:
// Chaturbate erstmal NICHT mit separatem Audio-Input öffnen.
// Wenn danach sofort .ts-Dateien entstehen, war der separate Audio-Input der Blocker.
useSeparateAudioInput = false
} else if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" {
inputURL = strings.TrimSpace(stream.MasterURL)
}
if audioURL != "" {
u, err := url.Parse(inputURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return appErrorf("ungültige input URL: %q", inputURL)
}
if useSeparateAudioInput {
au, err := url.Parse(audioURL)
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
return appErrorf("ungültige audio URL: %q", audioURL)
@ -471,15 +899,15 @@ func handleM3U8Mode(
appendInputOptions := func(args []string) []string {
args = append(args,
"-rw_timeout", "15000000",
// 30s statt 15s: CB/CDN hat gelegentlich kurze Hänger.
"-rw_timeout", "30000000",
// HLS-Segmente robuster laden:
// kurze Netzwerkhänger / einzelne kaputte Segment-Requests nicht sofort hart abbrechen.
"-reconnect", "1",
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
"-reconnect_delay_max", "4",
"-reconnect_delay_max", "10",
)
if ua != "" {
@ -497,7 +925,7 @@ func handleM3U8Mode(
return args
}
if audioURL != "" {
if useSeparateAudioInput {
args = appendInputOptions(args)
args = append(args, "-i", videoURL)
@ -514,7 +942,7 @@ func handleM3U8Mode(
} else {
args = appendInputOptions(args)
args = append(args,
"-i", videoURL,
"-i", inputURL,
"-map", "0:v:0",
"-map", "0:a:0?",
"-dn",
@ -574,7 +1002,7 @@ func handleM3U8Mode(
jobsMu.Unlock()
last = sz
_ = publishJob(job.ID)
_ = publishJobSnapshot(job.ID)
}
}
}
@ -599,7 +1027,7 @@ func handleM3U8Mode(
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJob(job.ID)
_ = publishJobSnapshot(job.ID)
}
}

View File

@ -30,7 +30,7 @@ func RecordStreamMFC(
) error {
mfc := NewMyFreeCams(username)
const waitPublicMax = 20 * time.Second
const waitPublicMax = 90 * time.Second
deadline := time.Now().Add(waitPublicMax)
for {
@ -51,7 +51,7 @@ func RecordStreamMFC(
}
// ✅ erst jetzt die Video URL holen (weil public)
stream, err := mfc.GetSelectedStream(false)
stream, err := mfc.GetSelectedStream(ctx, false, hc.userAgent)
if err != nil {
return appErrorf("mfc get video url: %w", err)
}
@ -134,7 +134,7 @@ func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
return m.VideoURL, nil
}
func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error) {
func (m *MyFreeCams) GetSelectedStream(ctx context.Context, refresh bool, userAgent string) (*selectedHLSStream, error) {
m3u8URL, err := m.GetVideoURL(refresh)
if err != nil {
return nil, err
@ -144,10 +144,10 @@ func (m *MyFreeCams) GetSelectedStream(refresh bool) (*selectedHLSStream, error)
}
return getWantedResolutionPlaylistWithHeaders(
context.Background(),
ctx,
m3u8URL,
"", // MFC normalerweise ohne Cookies
"", // optionaler UA, hier leer lassen oder später ergänzen
userAgent,
m.GetWebsiteURL(),
)
}
@ -243,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error {
return appErrorf("Stream ist nicht live (Status: %s)", st)
}
stream, err := mfc.GetSelectedStream(false)
stream, err := mfc.GetSelectedStream(ctx, false, "")
if err != nil {
return err
}

View File

@ -24,13 +24,14 @@ type cleanupCandidate struct {
}
type cleanupResp struct {
// Small downloads cleanup
ScannedFiles int `json:"scannedFiles"`
DeletedFiles int `json:"deletedFiles"`
SkippedFiles int `json:"skippedFiles"`
DeletedBytes int64 `json:"deletedBytes"`
DeletedBytesHuman string `json:"deletedBytesHuman"`
ErrorCount int `json:"errorCount"`
// Small / broken downloads cleanup
ScannedFiles int `json:"scannedFiles"`
DeletedFiles int `json:"deletedFiles"`
DeletedBrokenFiles int `json:"deletedBrokenFiles"`
SkippedFiles int `json:"skippedFiles"`
DeletedBytes int64 `json:"deletedBytes"`
DeletedBytesHuman string `json:"deletedBytesHuman"`
ErrorCount int `json:"errorCount"`
// Generated-GC
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
@ -233,7 +234,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
finishCleanupJob(
jobID,
cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes),
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
nil,
)
}(jobID, ctx, doneAbs, mb)
@ -265,7 +266,17 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
}
}
func cleanupProgressText(deleted int, total int, freedBytes int64) string {
func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string {
if broken > 0 {
return fmt.Sprintf(
"gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s",
deleted,
total,
broken,
formatBytesSI(freedBytes),
)
}
return fmt.Sprintf(
"gelöscht: %d/%d · freigegeben: %s",
deleted,
@ -274,6 +285,58 @@ func cleanupProgressText(deleted int, total int, freedBytes int64) string {
)
}
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
path = strings.TrimSpace(path)
if path == "" {
return false, ""
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".mp4" && ext != ".ts" {
return false, ""
}
fi, err := os.Stat(path)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return true, "file missing/empty"
}
// Erst ffprobe/duration: wenn keine Dauer lesbar ist, ist die Datei meistens kaputt.
dctx, cancel := context.WithTimeout(ctx, 8*time.Second)
dur, derr := durationSecondsCached(dctx, path)
dctxErr := dctx.Err()
cancel()
if errors.Is(dctxErr, context.DeadlineExceeded) || errors.Is(dctxErr, context.Canceled) {
// Timeout beim Prüfen nicht als "kaputt" werten, um keine guten Dateien wegen Last zu löschen.
return false, "duration check timeout"
}
if derr != nil || dur <= 0 {
if derr != nil {
return true, "duration failed: " + derr.Error()
}
return true, "duration <= 0"
}
// Danach konservative Decode-Prüfung am Ende.
// Diese Funktion nutzt du bereits nach dem Remux in recorder.go.
vctx, vcancel := context.WithTimeout(ctx, 20*time.Second)
verr := validateVideoDecodesNearEnd(vctx, path)
vctxErr := vctx.Err()
vcancel()
if errors.Is(vctxErr, context.DeadlineExceeded) || errors.Is(vctxErr, context.Canceled) {
return false, "decode check timeout"
}
if verr != nil {
return true, "decode failed: " + verr.Error()
}
return false, ""
}
func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error {
isCandidate := func(name string) bool {
low := strings.ToLower(name)
@ -377,7 +440,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
st.Done = 0
st.Total = resp.ScannedFiles
st.CurrentFile = ""
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Error = ""
st.FinishedAt = nil
})
@ -388,14 +451,34 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
return ctx.Err()
default:
}
deleteReason := ""
if c.size < threshold {
deleteReason = "small"
} else {
broken, why := cleanupVideoLooksBroken(ctx, c.path)
if broken {
deleteReason = "broken"
appLogln("🧹 cleanup deleting broken video:", filepath.Base(c.path), why)
}
}
if deleteReason != "" {
base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path))
id := stripHotPrefix(base)
// Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei,
// damit Windows-Dateihandles nicht blockieren.
releaseFileForMutation(c.name)
if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += c.size
if deleteReason == "broken" {
resp.DeletedBrokenFiles++
}
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
}
@ -414,8 +497,8 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
}
st.Done = i + 1
st.Total = resp.ScannedFiles
st.CurrentFile = ""
st.Text = cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes)
st.CurrentFile = c.name
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
st.Error = ""
st.FinishedAt = nil
})

View File

@ -1103,6 +1103,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const ok = window.confirm(
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)