This commit is contained in:
Linrador 2026-02-25 17:46:15 +01:00
parent ae67c817ac
commit a229e1ef2c
16 changed files with 668 additions and 606 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -255,10 +255,15 @@ func ensureVideoMetaForFileBestEffort(ctx context.Context, fullPath string, sour
}
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return nil, false
}
assetID, err = sanitizeID(assetID)
if err != nil || assetID == "" {
return nil, false
}
metaPath, err := metaJSONPathForAssetID(assetID)
if err != nil || strings.TrimSpace(metaPath) == "" {

Binary file not shown.

View File

@ -650,83 +650,21 @@ func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
return
}
// ID: basename ohne Ext + ohne HOT
base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
id := stripHotPrefix(base)
id = strings.TrimSpace(id)
if id == "" {
videoPath = strings.TrimSpace(videoPath)
if videoPath == "" {
return
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
fi, err := os.Stat(videoPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
return
}
// existiert schon?
if fi, err := os.Stat(metaPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
return
}
// Video stat für spätere Checks / Meta-Write
vfi, err := os.Stat(videoPath)
if err != nil || vfi == nil || vfi.IsDir() || vfi.Size() == 0 {
return
}
// kleiner Timeout: wir wollen Playback nicht “ewig” blockieren
pctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
// Dauer (best effort)
dur := 0.0
if d, derr := durationSecondsCached(pctx, videoPath); derr == nil && d > 0 {
dur = d
}
// Height/Width optional nicht mehr berechnen (wenn helper entfernt wurde)
h := 0
// FPS optional wenn du einen Cache/helper hast, nimm ihn; sonst 0 lassen.
fps := 0.0
// Quelle URL ist bei done-files oft nur in meta; wenn unbekannt, leer lassen.
srcURL := ""
// Sicherstellen, dass Ordner existiert
_ = os.MkdirAll(filepath.Dir(metaPath), 0o755)
// Format: passe an deine readVideoMeta(...) / readVideoMetaDuration(...) Parser-Struktur an!
// Ich nehme hier eine sehr typische Struktur an:
type videoMeta struct {
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
// optional: file info / updatedAt
UpdatedAtUnix int64 `json:"updatedAtUnix,omitempty"`
FileSizeBytes int64 `json:"fileSizeBytes,omitempty"`
}
m := videoMeta{
DurationSeconds: dur,
Width: 0,
Height: h,
FPS: fps,
SourceURL: srcURL,
UpdatedAtUnix: time.Now().Unix(),
FileSizeBytes: vfi.Size(),
}
// Atomisch schreiben (damit parallele Requests kein kaputtes JSON sehen)
tmp := metaPath + ".tmp"
if b, err := json.MarshalIndent(m, "", " "); err == nil {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, metaPath)
} else {
_ = os.Remove(tmp)
}
// ✅ Zentrale Meta-Logik benutzen (meta.go)
// - liest vorhandene gültige meta
// - erzeugt v2-meta bei Bedarf
// - fallbackt auf duration-cache-only meta, wenn ffprobe gerade nicht geht
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
}
func recordVideo(w http.ResponseWriter, r *http.Request) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-BZ38s29o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZMtb58J.css">
<script type="module" crossorigin src="/assets/index-C4whm-WW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3IFBscEU.css">
</head>
<body>
<div id="root"></div>

View File

@ -476,15 +476,48 @@ export default function App() {
// ✅ sagt FinishedDownloads: "bitte ALL neu laden"
const finishedReloadTimerRef = useRef<number | null>(null)
const finishedReloadLastDispatchAtRef = useRef(0)
const requestFinishedReload = useCallback(() => {
const requestFinishedReload = useCallback((reason = 'unknown') => {
const now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
// Wenn wir nicht im Finished-Tab sind, keine Reload-Events feuern.
// (Count kann separat aktualisiert werden.)
if (selectedTabRef.current !== 'finished') return
// Bereits geplant -> nur koaleszieren
if (finishedReloadTimerRef.current != null) {
window.clearTimeout(finishedReloadTimerRef.current)
return
}
// Harte Dedupe-Schranke
const sinceLast = now - finishedReloadLastDispatchAtRef.current
if (sinceLast < cooldownMs) {
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: `App.requestFinishedReload(cooldown-tail)`, reason },
})
)
}, cooldownMs - sinceLast)
return
}
// Leichtes Coalescing für Burst im selben Moment
finishedReloadTimerRef.current = window.setTimeout(() => {
finishedReloadTimerRef.current = null
window.dispatchEvent(new CustomEvent('finished-downloads:reload'))
finishedReloadLastDispatchAtRef.current = Date.now()
window.dispatchEvent(
new CustomEvent('finished-downloads:reload', {
detail: { source: 'App.requestFinishedReload', reason },
})
)
}, 150)
}, [])
@ -1249,12 +1282,12 @@ export default function App() {
// ✅ Badge/Count updaten + FinishedDownloads (ALL) reloaden
void loadDoneCount()
requestFinishedReload()
requestFinishedReload('selectedTab effect')
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
requestFinishedReload()
requestFinishedReload('selectedTab visibilitychange')
}
}
document.addEventListener('visibilitychange', onVis)
@ -1290,7 +1323,7 @@ export default function App() {
if (document.hidden) return
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
requestFinishedReload('done-stream poll tick')
} else {
void loadDoneCount()
}
@ -1312,7 +1345,7 @@ export default function App() {
lastFireRef.t = Date.now()
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
requestFinishedReload('done-stream coalesced requestRefresh')
} else {
void loadDoneCount()
}
@ -1323,7 +1356,7 @@ export default function App() {
lastFireRef.t = now
if (selectedTabRef.current === 'finished') {
void loadDoneCount()
requestFinishedReload()
requestFinishedReload('done-stream requestRefresh')
} else {
void loadDoneCount()
}
@ -1436,18 +1469,14 @@ export default function App() {
setDoneCount((c) => Math.max(0, c + delta))
}
// Count darf immer aktualisiert werden (Badge/Header)
// ✅ Nur Header/Badge nachziehen
// FinishedDownloads macht seinen Refill selbst.
void loadDoneCount()
// ✅ Nur Finished-Tab wirklich neu laden
if (selectedTabRef.current === 'finished') {
requestFinishedReload()
}
}
window.addEventListener('finished-downloads:count-hint', onHint as EventListener)
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [loadDoneCount, requestFinishedReload])
}, [loadDoneCount])
useEffect(() => {
const onNav = (ev: Event) => {

View File

@ -352,6 +352,9 @@ export default function FinishedDownloads({
const refillInFlightRef = React.useRef(false)
const refillQueuedWhileInFlightRef = React.useRef(false)
// ✅ schützt gegen alte Effect-Instanzen / StrictMode-Cleanup / Race Conditions
const refillSessionRef = React.useRef(0)
type UndoAction =
| { kind: 'delete'; undoToken: string; originalFile: string; rowKey?: string; from?: 'done' | 'keep' }
| { kind: 'keep'; keptFile: string; originalFile: string; rowKey?: string }
@ -416,12 +419,6 @@ export default function FinishedDownloads({
const [refillTick, setRefillTick] = React.useState(0)
const refillTimerRef = React.useRef<number | null>(null)
const queueRefill = useCallback(() => {
if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current)
// kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen
refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80)
}, [])
const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any })
const emitCountHint = React.useCallback((delta: number) => {
@ -552,6 +549,16 @@ export default function FinishedDownloads({
}
}, []) // nur einmal beim Mount
const queueRefill = useCallback(() => {
// ✅ Schon geplant? Dann nicht nochmal planen.
if (refillTimerRef.current != null) return
refillTimerRef.current = window.setTimeout(() => {
refillTimerRef.current = null
setRefillTick((n) => n + 1)
}, 80)
}, [page, pageSize, sortMode, includeKeep])
useEffect(() => {
if (!effectiveAllMode) return
@ -573,19 +580,32 @@ export default function FinishedDownloads({
const ac = new AbortController()
let alive = true
let finished = false // ✅ pro Effect-Instanz idempotent
// ✅ eigene Session-ID für diese Effect-Instanz
const mySession = ++refillSessionRef.current
const finishRefill = () => {
if (finished) return
finished = true
// ✅ Nur die AKTUELLE Session darf den globalen Refill-Status verändern
if (refillSessionRef.current !== mySession) {
return
}
refillInFlightRef.current = false
// ✅ Nachlauf-Reload ausführen, falls während des laufenden Refills ein Event kam
if (refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = false
queueRefill()
}
}
// ✅ Refill läuft
refillInFlightRef.current = true
// ✅ Refill läuft (nur wenn diese Session noch aktuell ist)
if (refillSessionRef.current === mySession) {
refillInFlightRef.current = true
}
// ✅ Wenn Filter aktiv: nicht paginiert ziehen, sondern "all"
if (effectiveAllMode) {
@ -615,23 +635,17 @@ export default function FinishedDownloads({
;(async () => {
try {
// 1) Liste + count holen
const [listRes, metaRes] = await Promise.all([
fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
fetch(
`/api/record/done/meta${includeKeep ? '?includeKeep=1' : ''}`,
{ cache: 'no-store' as any, signal: ac.signal }
),
])
// 1) Liste + optional count in EINEM Request holen
const listRes = await fetch(
`/api/record/done?page=${page}&pageSize=${pageSize}&sort=${encodeURIComponent(sortMode)}&withCount=1${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any, signal: ac.signal }
)
if (!alive || ac.signal.aborted) return
let okAll = listRes.ok && metaRes.ok
const okAll = listRes.ok
if (listRes.ok) {
const data = await listRes.json().catch(() => null)
@ -644,13 +658,9 @@ export default function FinishedDownloads({
: []
setOverrideDoneJobs(items)
}
if (metaRes.ok) {
const meta = await metaRes.json().catch(() => null)
if (!alive || ac.signal.aborted) return
const countRaw = Number(meta?.count ?? 0)
// ✅ Count direkt aus /done lesen (withCount=1)
const countRaw = Number(data?.count ?? data?.totalCount ?? items.length)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
setOverrideDoneTotal(count)
@ -1796,12 +1806,15 @@ export default function FinishedDownloads({
useEffect(() => {
const onReload = () => {
// ✅ Wenn gerade ein Refill läuft, Reload nicht verlieren, sondern merken
if (refillInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
if (!refillQueuedWhileInFlightRef.current) {
refillQueuedWhileInFlightRef.current = true
}
return
}
if (refillTimerRef.current != null) return
queueRefill()
}
@ -2108,7 +2121,6 @@ export default function FinishedDownloads({
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
/>
</div>
@ -2267,7 +2279,6 @@ export default function FinishedDownloads({
onChange={(checked) => {
if (page !== 1) onPageChange(1)
setIncludeKeep(checked)
queueRefill()
}}
ariaLabel="Behaltene Downloads anzeigen"
size="default"

View File

@ -617,7 +617,7 @@ export default function FinishedDownloadsCardsView({
? undefined
: registerTeaserHost(k)
}
className="relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
onMouseEnter={
isSmall || opts?.disablePreviewHover ? undefined : () => onHoverPreviewKeyChange?.(k)
}
@ -639,7 +639,7 @@ export default function FinishedDownloadsCardsView({
<div
className={
'pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 ' +
'group-hover:opacity-0 group-focus-within:opacity-0 ' +
'group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0 ' +
(inlineActive ? 'opacity-0' : 'opacity-100')
}
>

View File

@ -462,7 +462,7 @@ export default function FinishedDownloadsGalleryView({
>
{/* Thumb */}
<div
className="group relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
ref={registerTeaserHostIfNeeded(k)}
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
onMouseLeave={() => {
@ -566,7 +566,7 @@ export default function FinishedDownloadsGalleryView({
) : null}
{/* Meta-Overlay im Video: unten rechts */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover:opacity-0 group-focus-within:opacity-0">
<div className="pointer-events-none absolute right-2 bottom-2 z-10 transition-opacity duration-150 group-hover/thumb:opacity-0 group-focus-within/thumb:opacity-0">
<div
className="flex items-center gap-1.5 text-right text-[11px] font-semibold leading-none text-white [text-shadow:_0_1px_0_rgba(0,0,0,0.95),_1px_0_0_rgba(0,0,0,0.95),_-1px_0_0_rgba(0,0,0,0.95),_0_-1px_0_rgba(0,0,0,0.95),_1px_1px_0_rgba(0,0,0,0.8),_-1px_1px_0_rgba(0,0,0,0.8),_1px_-1px_0_rgba(0,0,0,0.8),_-1px_-1px_0_rgba(0,0,0,0.8)]"
title={[dur, resObj ? `${resObj.w}×${resObj.h}` : resLabel || '', size]

View File

@ -141,6 +141,9 @@ export default function FinishedVideoPreview({
// ✅ falls job.meta keine previewClips enthält: meta.json nachladen
const [fetchedMeta, setFetchedMeta] = useState<any | null>(null)
// ✅ verhindert mehrfachen fallback-fetch pro Datei in derselben Komponenteninstanz
const fetchedMetaFilesRef = useRef<Set<string>>(new Set())
// ✅ merge statt "meta ?? fetchedMeta"
// job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips)
const metaForPreview = useMemo(() => {
@ -150,6 +153,32 @@ export default function FinishedVideoPreview({
return { ...meta, ...fetchedMeta }
}, [meta, fetchedMeta])
// ✅ stabiler Guard für den fallback-fetch (berücksichtigt auch bereits geholtes fetchedMeta)
const hasPreviewClipsInAnyMeta = useMemo(() => {
let pcs: any = (metaForPreview as any)?.previewClips
if (typeof pcs === 'string') {
const s = pcs.trim()
if (s.length > 0) {
try {
const parsed = JSON.parse(s)
if (Array.isArray(parsed) && parsed.length > 0) return true
// falls string vorhanden aber nicht parsebar, behandeln wir ihn trotzdem als "vorhanden"
return true
} catch {
return true
}
}
}
if (Array.isArray(pcs) && pcs.length > 0) return true
const nested = (metaForPreview as any)?.preview?.clips
if (Array.isArray(nested) && nested.length > 0) return true
return false
}, [metaForPreview])
const [progressMountTick, setProgressMountTick] = useState(0)
// previewClips mapping: preview.mp4 ist Concatenation von Segmenten
@ -299,6 +328,10 @@ export default function FinishedVideoPreview({
const [teaserReady, setTeaserReady] = useState(false)
const [teaserOk, setTeaserOk] = useState(true)
// ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent)
const emittedDurationRef = useRef<string>('')
const emittedResolutionRef = useRef<string>('')
// inView (Viewport)
const rootRef = useRef<HTMLDivElement | null>(null)
const [inView, setInView] = useState(false)
@ -443,6 +476,12 @@ export default function FinishedVideoPreview({
useEffect(() => {
setTeaserReady(false)
setTeaserOk(true)
setFetchedMeta(null) // ✅ neue Datei/Asset -> alten fallback-meta cache in state verwerfen
// ✅ neue Datei/Asset -> Callback-Dedupe zurücksetzen
emittedDurationRef.current = ''
emittedResolutionRef.current = ''
setMetaLoaded(false)
}, [previewId, assetNonce, noGenerateTeaser])
useEffect(() => {
@ -589,34 +628,50 @@ export default function FinishedVideoPreview({
const shouldPreloadAnimatedAssets = nearView || inView || everInView || (wantsHover && hovered)
// ✅ Wenn meta.json schon alles hat: sofort Callbacks auslösen (kein hidden <video> nötig)
// aber pro Datei/Wert nur 1x (verhindert doppelte Parent-Requests)
useEffect(() => {
let did = false
if (onDuration && hasDuration) {
onDuration(job, Number(effectiveDurationSec))
did = true
const secs = Number(effectiveDurationSec)
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
did = true
}
}
if (onResolution && hasResolution) {
onResolution(job, Number(effectiveW), Number(effectiveH))
did = true
const w = Number(effectiveW)
const h = Number(effectiveH)
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h)
did = true
}
}
if (did) setMetaLoaded(true)
}, [job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
}, [file, job, onDuration, onResolution, hasDuration, hasResolution, effectiveDurationSec, effectiveW, effectiveH])
// ✅ meta fallback fetch: nur wenn previewClips fehlen (für teaser-sprünge)
// und pro Datei in dieser Komponenteninstanz nur 1x
useEffect(() => {
if (!previewId) return
if (!file) return
if (!animated || animatedMode !== 'teaser') return
if (!(shouldPreloadAnimatedAssets)) return
if (!shouldPreloadAnimatedAssets) return
const pcs = (meta as any)?.previewClips
const hasPcs =
Array.isArray(pcs) || (typeof pcs === 'string' && pcs.length > 0) || Array.isArray((meta as any)?.preview?.clips)
// ✅ wenn wir schon previewClips aus job.meta ODER fetchedMeta haben -> kein Request
if (hasPreviewClipsInAnyMeta) return
if (hasPcs) return
// ✅ gleicher file-Request in dieser Instanz schon gemacht -> nicht nochmal feuern
if (fetchedMetaFilesRef.current.has(file)) return
let aborted = false
const ctrl = new AbortController()
@ -636,15 +691,29 @@ export default function FinishedVideoPreview({
}
;(async () => {
const byFile = file ? await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`) : null
if (!aborted && byFile) setFetchedMeta(byFile)
const byFile = await tryFetch(`/api/record/done/meta?file=${encodeURIComponent(file)}`)
if (aborted) return
// ✅ als "versucht" markieren (auch wenn null), damit nearView/inView/hover nicht spammt
fetchedMetaFilesRef.current.add(file)
if (byFile) {
setFetchedMeta(byFile)
}
})()
return () => {
aborted = true
ctrl.abort()
}
}, [previewId, file, animated, animatedMode, meta, shouldPreloadAnimatedAssets])
}, [
previewId,
file,
animated,
animatedMode,
shouldPreloadAnimatedAssets,
hasPreviewClipsInAnyMeta,
])
// ✅ Fallback: nur wenn wir wirklich noch nichts haben, über loadedmetadata nachladen
const handleLoadedMetadata = (e: SyntheticEvent<HTMLVideoElement>) => {
@ -654,14 +723,24 @@ export default function FinishedVideoPreview({
if (onDuration && !hasDuration) {
const secs = Number(vv.duration)
if (Number.isFinite(secs) && secs > 0) onDuration(job, secs)
if (Number.isFinite(secs) && secs > 0) {
const durationKey = `${file}|dur|${secs}`
if (emittedDurationRef.current !== durationKey) {
emittedDurationRef.current = durationKey
onDuration(job, secs)
}
}
}
if (onResolution && !hasResolution) {
const w = Number(vv.videoWidth)
const h = Number(vv.videoHeight)
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
onResolution(job, w, h)
const resKey = `${file}|res|${w}x${h}`
if (emittedResolutionRef.current !== resKey) {
emittedResolutionRef.current = resKey
onResolution(job, w, h)
}
}
}
}