This commit is contained in:
Linrador 2026-03-27 08:38:25 +01:00
parent 013f616303
commit 12fc1e2f83
6 changed files with 124 additions and 38 deletions

View File

@ -505,10 +505,46 @@ func detectFFmpegPath() string {
return "ffmpeg" return "ffmpeg"
} }
func removeGeneratedForID(id string) { func isAssetIDInUse(id string) bool {
// ✅ canonical id: wie beim Erzeugen der generated Ordner
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id == "" { if id == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
aid := strings.TrimSpace(assetIDForJob(j))
if aid == "" || !strings.EqualFold(aid, id) {
continue
}
// Alles was noch nicht terminal ist, gilt als "in Benutzung"
if !isTerminalJobStatus(j.Status) {
return true
}
// Auch Postwork/Queue lieber schützen
if isPostworkJob(j) {
return true
}
}
return false
}
func removeGeneratedForID(id string) {
id = strings.TrimSpace(id)
if id == "" {
return
}
if isAssetIDInUse(id) {
return return
} }
@ -530,11 +566,6 @@ func removeGeneratedForID(id string) {
_ = os.RemoveAll(filepath.Join(root, id)) _ = os.RemoveAll(filepath.Join(root, id))
} }
// (optional aber sinnvoll) 1b) Legacy: generated/<id>/ (falls noch alte Assets existieren)
if root, _ := generatedRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id))
}
// 2) Temp Preview Segmente (HLS) wegräumen // 2) Temp Preview Segmente (HLS) wegräumen
// (%TEMP%/rec_preview/<assetID>) // (%TEMP%/rec_preview/<assetID>)
_ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id)) _ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id))

View File

@ -1623,7 +1623,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
if previewDir != "" { if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir) img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
if genErr != nil { if genErr != nil {
fmt.Println("preview from previewDir failed:", previewDir, genErr) //fmt.Println("preview from previewDir failed:", previewDir, genErr)
} }
} }

View File

@ -79,11 +79,7 @@ func RecordStream(
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil { if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
segmentsDir = strings.TrimSpace(sdir) segmentsDir = strings.TrimSpace(sdir)
} else {
fmt.Println("preview ensurePreviewSegmentsDir failed:", assetID, serr)
} }
} else {
fmt.Println("preview ensureGeneratedDir failed:", assetID, derr)
} }
} }
@ -191,6 +187,7 @@ func RecordStream(
} }
if segmentsDir != "" { if segmentsDir != "" {
if err := os.MkdirAll(segmentsDir, 0o755); err == nil {
ext := ".m4s" ext := ".m4s"
if len(b) > 0 && b[0] == 0x47 { if len(b) > 0 && b[0] == 0x47 {
ext = ".ts" ext = ".ts"
@ -199,12 +196,11 @@ func RecordStream(
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext) segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
segPath := filepath.Join(segmentsDir, segName) segPath := filepath.Join(segmentsDir, segName)
if err := os.WriteFile(segPath, b, 0o644); err != nil { if err := os.WriteFile(segPath, b, 0o644); err == nil {
fmt.Println("preview segment write failed:", segPath, err)
} else {
trimPreviewSegments(segmentsDir, 12) trimPreviewSegments(segmentsDir, 12)
} }
} }
}
if job != nil && !published { if job != nil && !published {
published = true published = true
@ -301,7 +297,6 @@ func (p *Playlist) WatchSegments(
segDir, err := ensurePreviewSegmentsDir(previewDir) segDir, err := ensurePreviewSegmentsDir(previewDir)
if err != nil { if err != nil {
fmt.Println("ensurePreviewSegmentsDir failed:", previewDir, err)
return return
} }
initPath := filepath.Join(segDir, "init.mp4") initPath := filepath.Join(segDir, "init.mp4")
@ -315,35 +310,29 @@ func (p *Playlist) WatchSegments(
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie) req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
if err != nil { if err != nil {
fmt.Println("init request build failed:", mapURL, err)
return return
} }
resp, err := hc.client.Do(req) resp, err := hc.client.Do(req)
if err != nil { if err != nil {
fmt.Println("init download failed:", mapURL, err)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Println("init bad status:", resp.StatusCode, mapURL)
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
return return
} }
b, err := io.ReadAll(resp.Body) b, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
fmt.Println("init read failed:", mapURL, err)
return return
} }
if len(b) == 0 { if len(b) == 0 {
fmt.Println("init empty:", mapURL)
return return
} }
if err := os.WriteFile(initPath, b, 0o644); err != nil { if err := os.WriteFile(initPath, b, 0o644); err != nil {
fmt.Println("init write failed:", initPath, err)
return return
} }
@ -369,7 +358,7 @@ func (p *Playlist) WatchSegments(
resp, err := hc.client.Do(req) resp, err := hc.client.Do(req)
if err != nil { if err != nil {
emptyRounds++ emptyRounds++
fmt.Println("playlist download failed:", p.PlaylistURL, err) //fmt.Println("playlist download failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline") return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline")
@ -405,7 +394,6 @@ func (p *Playlist) WatchSegments(
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
emptyRounds++ emptyRounds++
fmt.Println("playlist decode failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline") return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
@ -421,7 +409,6 @@ func (p *Playlist) WatchSegments(
if listType != m3u8.MEDIA { if listType != m3u8.MEDIA {
emptyRounds++ emptyRounds++
fmt.Println("playlist is not media playlist:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline") return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
@ -438,7 +425,6 @@ func (p *Playlist) WatchSegments(
media, ok := playlist.(*m3u8.MediaPlaylist) media, ok := playlist.(*m3u8.MediaPlaylist)
if !ok || media == nil { if !ok || media == nil {
emptyRounds++ emptyRounds++
fmt.Println("playlist cast to media failed:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds { if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Media-Playlist möglicherweise offline") return errors.New("❌ Fehlerhafte Media-Playlist möglicherweise offline")

View File

@ -310,6 +310,23 @@ func runGeneratedGarbageCollector() generatedGCStats {
return nil return nil
}) })
// 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln.
// Sonst räumt der GC generated/meta/<id> weg, obwohl RecordStream dort noch schreibt.
jobsMu.Lock()
for _, j := range jobs {
if j == nil {
continue
}
id := strings.TrimSpace(assetIDForJob(j))
if id == "" {
continue
}
live[id] = struct{}{}
}
jobsMu.Unlock()
// 2) /generated/meta/<id> prüfen // 2) /generated/meta/<id> prüfen
metaRoot, err := generatedMetaRoot() metaRoot, err := generatedMetaRoot()
if err == nil { if err == nil {

View File

@ -2860,6 +2860,10 @@ export default function App() {
// ✅ Prefetch wieder nachfüllen // ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
// ✅ wichtig
requestFinishedReload('delete success')
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -2888,16 +2892,48 @@ export default function App() {
run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }), run: () => apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }),
onSuccess: async () => { onSuccess: async () => {
window.setTimeout(() => { window.setTimeout(() => {
// ✅ Entfernt aus Finished + Running + Player // ✅ Done-Liste lokal bereinigen + Seite direkt wieder auffüllen (aus Prefetch)
setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setDoneJobs((prev) => {
const filtered = prev.filter((j) => baseName(j.output || '') !== file)
const need = DONE_PAGE_SIZE - filtered.length
if (need <= 0) return filtered
const prefetchKey = makePrefetchKey(donePage + 1, doneSort)
const buf = donePrefetchRef.current
if (!buf || buf.key !== prefetchKey || !Array.isArray(buf.items) || buf.items.length === 0) {
return filtered
}
const next: RecordJob[] = [...filtered]
const used = new Set(next.map((x) => String(x.id || baseName(x.output || '')).trim()))
while (next.length < DONE_PAGE_SIZE && buf.items.length > 0) {
const cand = buf.items.shift()!
const id = String(cand.id || baseName(cand.output || '')).trim()
if (!id || used.has(id)) continue
used.add(id)
next.push(cand)
}
donePrefetchRef.current = { ...buf, items: buf.items, ts: buf.ts }
return next
})
// ✅ Entfernt aus Running + Player
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
// ✅ Count ebenfalls runter (Keep entfernt den Eintrag aus "finished") // ✅ Count runter
setDoneCount((c) => Math.max(0, c - 1)) setDoneCount((c) => Math.max(0, c - 1))
// Optional: nächste Seite vorladen (schadet nicht) // ✅ Prefetch wieder nachfüllen
void prefetchDonePage(donePage + 1) void prefetchDonePage(donePage + 1)
// ✅ Finished-Liste aktiv neu laden
requestFinishedReload('keep success')
void loadDoneCount()
}, 320) }, 320)
}, },
onError: async () => { onError: async () => {
@ -2908,7 +2944,15 @@ export default function App() {
return return
} }
}, },
[runFinishedFileAction, notify, donePage, prefetchDonePage] [
runFinishedFileAction,
notify,
donePage,
doneSort,
prefetchDonePage,
requestFinishedReload,
loadDoneCount,
]
) )
const handleToggleHot = useCallback( const handleToggleHot = useCallback(

View File

@ -3126,6 +3126,14 @@ export default function FinishedDownloads({
if (detail.phase === 'success') { if (detail.phase === 'success') {
markDeleting(key, false) markDeleting(key, false)
// ✅ Nach erfolgreichem externen Delete aktuelle Seite nachziehen
if (refillInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
} else {
queueRefill()
}
return return
} }
} }