fixed finished downloads

This commit is contained in:
Linrador 2026-06-06 14:58:55 +02:00
parent 1625952638
commit 1a559fe76d
8 changed files with 227 additions and 191 deletions

View File

@ -224,6 +224,21 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Neuer Cleanup-Abschnitt: alten 100%-Fortschritt vom Small-Files-Scan zurücksetzen.
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
if st.StartedAt.IsZero() {
st.StartedAt = time.Now()
}
st.Done = 0
st.Total = 0
st.CurrentFile = ""
st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
})
if err := cleanupRecordDirOrphanAVFiles(ctx, jobID, recordAbs, &resp); err != nil {
finishCleanupJob(jobID, "", err)
return
@ -236,7 +251,19 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
default:
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
st.Done = 0
st.Total = 0
st.CurrentFile = ""
st.Text = "Räume Generated-Assets auf…"
st.Error = ""
st.FinishedAt = nil
})
gcStats := triggerGeneratedGarbageCollectorSync()
resp.GeneratedOrphansChecked = gcStats.Checked
resp.GeneratedOrphansRemoved = gcStats.Removed
resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes
@ -494,8 +521,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
}
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Queued = false
st.Running = true
st.Done = 0
st.Total = 0
st.CurrentFile = name
st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
})
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {

View File

@ -108,7 +108,7 @@ func runTray(onQuit func(), statsFn func() TrayStats) {
updateStatus()
case <-mOpenFrontend.ClickedCh:
_ = openBrowser("http://localhost:9999")
_ = openBrowser("https://localhost:9999")
case <-mOpenRecordDir.ClickedCh:
_ = openFolder(resolveConfiguredDir(getSettings().RecordDir))

View File

@ -914,10 +914,16 @@ export default function App() {
type DonePrefetch = {
key: string
items: RecordJob[]
totalCount: number
ts: number
}
const makePrefetchKey = (page: number, sort: DoneSortMode) => `${sort}::${page}`
const makePrefetchKey = (
page: number,
sort: DoneSortMode,
pageSize: number,
keep: boolean
) => `${sort}::${pageSize}::${keep ? 1 : 0}::${page}`
const getDoneTotalPages = (count: number, pageSize = donePageSize) => {
const safeCount = Number.isFinite(count) && count > 0 ? count : 0
@ -928,7 +934,7 @@ export default function App() {
if (pageToFetch < 1) return
if (donePrefetchInFlightRef.current) return
const key = makePrefetchKey(pageToFetch, doneSort)
const key = makePrefetchKey(pageToFetch, doneSort, donePageSize, includeKeep)
const cur = donePrefetchRef.current
if (cur?.key === key && Date.now() - cur.ts < 15_000) {
// frisch genug
@ -938,7 +944,7 @@ export default function App() {
donePrefetchInFlightRef.current = true
try {
const res = await fetch(
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}${
`/api/record/done?page=${pageToFetch}&pageSize=${donePageSize}&sort=${encodeURIComponent(doneSort)}&withCount=1${
includeKeep ? '&includeKeep=1' : ''
}`,
{ cache: 'no-store' as any }
@ -952,66 +958,23 @@ export default function App() {
? (data as RecordJob[])
: []
donePrefetchRef.current = { key, items, ts: Date.now() }
const totalCountRaw = Number(data?.count ?? data?.totalCount ?? 0)
const totalCount =
Number.isFinite(totalCountRaw) && totalCountRaw >= 0
? totalCountRaw
: 0
donePrefetchRef.current = {
key,
items,
totalCount,
ts: Date.now(),
}
} finally {
donePrefetchInFlightRef.current = false
}
}, [doneSort, includeKeep, donePageSize])
function doneJobMergeKey(job: RecordJob): string {
const output = String(job?.output ?? '').trim()
const id = String((job as any)?.id ?? '').trim()
if (output) return output
if (id) return id
return JSON.stringify([
String((job as any)?.sourceUrl ?? ''),
String((job as any)?.startedAt ?? ''),
String((job as any)?.endedAt ?? ''),
])
}
function mergeDoneJobsStable(
prev: RecordJob[],
incoming: RecordJob[]
): RecordJob[] {
if (!Array.isArray(prev) || prev.length === 0) return incoming
if (!Array.isArray(incoming) || incoming.length === 0) return prev
const incomingByKey = new Map<string, RecordJob>()
for (const item of incoming) {
incomingByKey.set(doneJobMergeKey(item), item)
}
const seen = new Set<string>()
const merged: RecordJob[] = []
// 1) Bestehende sichtbare Reihenfolge beibehalten,
// aber Daten der vorhandenen Elemente aktualisieren.
for (const oldItem of prev) {
const key = doneJobMergeKey(oldItem)
const nextItem = incomingByKey.get(key)
if (nextItem) {
merged.push(nextItem)
seen.add(key)
} else {
merged.push(oldItem)
}
}
// 2) Wirklich neue Finished-Downloads hinten anhängen,
// ohne bestehende Karten umzuschichten.
for (const item of incoming) {
const key = doneJobMergeKey(item)
if (seen.has(key)) continue
merged.push(item)
}
return merged
}
const loadDoneCount = useCallback(async () => {
const now = Date.now()
@ -1043,20 +1006,33 @@ export default function App() {
const loadDonePage = useCallback(async (
pageToLoad = donePage,
sortToLoad = doneSort,
opts?: { preserveOrder?: boolean }
sortToLoad = doneSort
) => {
try {
const preserveOrder = Boolean(opts?.preserveOrder)
const key = makePrefetchKey(pageToLoad, sortToLoad)
const key = makePrefetchKey(
pageToLoad,
sortToLoad,
donePageSize,
includeKeep
)
const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs((prev) =>
preserveOrder ? mergeDoneJobsStable(prev, prefetched.items) : prefetched.items
)
setDoneJobs(prefetched.items)
const count = Number(prefetched.totalCount)
if (Number.isFinite(count) && count >= 0 && count !== doneCount) {
setDoneCount(count)
}
donePrefetchRef.current = null
const totalPages = getDoneTotalPages(count, donePageSize)
if (pageToLoad < totalPages) {
void prefetchDonePage(pageToLoad + 1)
}
return
}
@ -1066,6 +1042,7 @@ export default function App() {
}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
@ -1074,9 +1051,7 @@ export default function App() {
? (data.items as RecordJob[])
: []
setDoneJobs((prev) =>
preserveOrder ? mergeDoneJobsStable(prev, items) : items
)
setDoneJobs(items)
const countRaw = Number(data?.count ?? doneCount)
const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0
@ -1093,6 +1068,12 @@ export default function App() {
// ignore
}
}, [donePage, doneSort, donePageSize, prefetchDonePage, includeKeep, doneCount])
const loadDonePageRef = useRef(loadDonePage)
useEffect(() => {
loadDonePageRef.current = loadDonePage
}, [loadDonePage])
useEffect(() => {
try {
@ -2773,7 +2754,8 @@ export default function App() {
void loadTaskStateOnce()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
}
}, document.hidden ? 60000 : 5000)
}
@ -2802,9 +2784,8 @@ export default function App() {
void loadDoneCount()
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current, {
preserveOrder: true,
})
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
}
}
@ -3009,7 +2990,8 @@ export default function App() {
void loadAutostartState().catch(() => {})
if (selectedTabRef.current === 'finished') {
void loadDonePage(donePageRef.current, doneSortRef.current)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
}
}
@ -3306,47 +3288,6 @@ export default function App() {
}
}
const fillDonePageFromPrefetch = useCallback((opts?: { removedFile?: string }) => {
const removedFile = String(opts?.removedFile ?? '').trim()
setDoneJobs((prev) => {
const filtered = removedFile
? prev.filter((j) => baseName(j.output || '') !== removedFile)
: [...prev]
const need = Math.max(0, donePageSize - 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 < donePageSize && 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
})
}, [donePage, donePageSize, doneSort])
const doneCountRef = useRef(doneCount)
useEffect(() => {
@ -3369,18 +3310,12 @@ export default function App() {
),
onSuccess: async () => {
window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
const nextDoneCount = Math.max(0, doneCountRef.current - 1)
const totalPagesAfterDelete = getDoneTotalPages(nextDoneCount, donePageSize)
if (donePage < totalPagesAfterDelete) {
void prefetchDonePage(donePage + 1)
}
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
}, 320)
},
@ -3400,9 +3335,6 @@ export default function App() {
[
runFinishedFileAction,
notify,
fillDonePageFromPrefetch,
donePage,
prefetchDonePage,
loadDoneCount,
]
)
@ -3423,9 +3355,12 @@ export default function App() {
),
onSuccess: async () => {
window.setTimeout(() => {
fillDonePageFromPrefetch({ removedFile: file })
setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file))
setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev))
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
}, 320)
},
@ -3439,7 +3374,7 @@ export default function App() {
return
}
},
[runFinishedFileAction, notify, fillDonePageFromPrefetch, loadDoneCount]
[runFinishedFileAction, notify, loadDoneCount]
)
const handleToggleHot = useCallback(
@ -3493,13 +3428,18 @@ export default function App() {
})
)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
return res
} catch (e: any) {
notify.error('Umbenennen fehlgeschlagen: ', baseName(job.output))
return
}
},
[notify]
[notify, loadDoneCount]
)
const handleDeleteJob = useCallback(
@ -4541,7 +4481,8 @@ export default function App() {
}
bumpAssets()
void loadDonePage(donePageRef.current, doneSortRef.current)
donePrefetchRef.current = null
void loadDonePageRef.current(donePageRef.current, doneSortRef.current)
void loadDoneCount()
} catch (err) {
const message =

View File

@ -1850,62 +1850,50 @@ function RatingHeroHeader({
toneLabel,
segmentsCount,
entriesCount,
onClose,
}: {
stars: number
toneLabel: string
segmentsCount: number
entriesCount: number
onClose?: () => void
}) {
const tone = ratingTone(stars)
const progress = Math.max(0, Math.min(100, Math.round((stars / 5) * 100)))
return (
<div className="relative overflow-hidden rounded-xl border border-white/10 bg-gray-950 px-3 py-2 text-white shadow-lg">
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-400/12 blur-2xl" />
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-500/10 blur-2xl" />
<div
className={[
'relative overflow-hidden rounded-xl border px-3 py-2 shadow-sm',
'border-gray-200 bg-white text-gray-950 ring-1 ring-black/5',
'dark:border-white/10 dark:bg-gray-950 dark:text-white dark:ring-white/10',
].join(' ')}
>
<div className="absolute -right-8 -top-8 size-20 rounded-full bg-amber-300/20 blur-2xl dark:bg-amber-400/12" />
<div className="absolute -left-8 bottom-0 size-16 rounded-full bg-indigo-300/15 blur-2xl dark:bg-indigo-500/10" />
<div className="relative flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[13px] font-black leading-4 tracking-tight">
<div className="truncate text-[13px] font-black leading-4 tracking-tight text-gray-950 dark:text-white">
AI Rating
</div>
<span className="shrink-0 rounded-full bg-white/10 px-1.5 py-[2px] text-[9px] font-bold leading-none text-white/75 ring-1 ring-white/15">
<span className="shrink-0 rounded-full bg-gray-100 px-1.5 py-[2px] text-[9px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-white/75 dark:ring-white/15">
{toneLabel}
</span>
</div>
<div className="mt-0.5 truncate text-[10px] leading-3 text-white/55">
<div className="mt-0.5 truncate text-[10px] leading-3 text-gray-500 dark:text-white/55">
{stars}/5 · {segmentsCount} Stellen · {entriesCount} Details
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<RatingStar stars={stars} className="h-7 w-7" />
{onClose ? (
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/10 text-lg leading-none text-white/75 ring-1 ring-white/15 transition hover:bg-white/18 hover:text-white"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onClose()
}}
aria-label="Schließen"
title="Schließen"
>
×
</button>
) : null}
</div>
</div>
<div className="relative mt-2">
<div className="h-1 overflow-hidden rounded-full bg-white/12">
<div className="h-1 overflow-hidden rounded-full bg-gray-200 dark:bg-white/12">
<div
className={[
'h-full rounded-full transition-all duration-300',
@ -2328,7 +2316,6 @@ function RatingMobileSheet({
toneLabel={toneLabel}
segmentsCount={segments.length}
entriesCount={entries.length}
onClose={closeWithAnimation}
/>
</div>

View File

@ -223,7 +223,12 @@ function SettingsSection(props: {
<button
type="button"
onClick={toggleOpen}
className="flex w-full items-start justify-between gap-4 rounded-2xl bg-white/95 p-4 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400"
className={[
'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500',
'dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400',
open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl',
].join(' ')}
aria-expanded={open}
>
<div className="min-w-0">

View File

@ -498,17 +498,20 @@ export function ToastProvider({
const ctx = useMemo<ToastContextValue>(() => ({ push, remove, clear }), [push, remove, clear])
const posCls =
position === 'top-right'
? 'items-start sm:items-start sm:justify-start'
: position === 'top-left'
? 'items-start sm:items-start sm:justify-start'
: position === 'bottom-left'
? 'items-end sm:items-end sm:justify-end'
: 'items-end sm:items-end sm:justify-end'
const desktopPosCls =
position.startsWith('top')
? 'sm:items-start'
: 'sm:items-end'
const alignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end'
const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'bottom-0 top-auto'
const desktopAlignCls = position.endsWith('left')
? 'sm:items-start'
: 'sm:items-end'
// Mobile immer oben mittig.
// Desktop bleibt wie über `position` gesteuert.
const insetCls = position.startsWith('top')
? 'top-0 bottom-auto'
: 'top-0 bottom-auto sm:bottom-0 sm:top-auto'
return (
<ToastContext.Provider value={ctx}>
@ -534,8 +537,18 @@ export function ToastProvider({
aria-live="assertive"
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
>
<div className={['flex w-full px-3 py-4 sm:px-6 sm:py-6', posCls].join(' ')}>
<div className={['flex w-full flex-col gap-2.5 sm:gap-3', alignCls].join(' ')}>
<div
className={[
'flex w-full justify-center px-3 py-4 sm:px-6 sm:py-6',
desktopPosCls,
].join(' ')}
>
<div
className={[
'flex w-full max-w-[22rem] flex-col items-center gap-2.5 sm:max-w-none sm:gap-3',
desktopAlignCls,
].join(' ')}
>
{toasts.map((t) => {
const { Icon, cls } = iconFor(t.type)
const accents = accentFor(t.type)

View File

@ -5305,8 +5305,33 @@ export default function TrainingTab(props: {
if (!rect || rect.width <= 0 || rect.height <= 0) return null
const size = 156
const padding = 20
const visualViewport =
typeof window !== 'undefined' ? window.visualViewport : undefined
const viewportLeft = visualViewport?.offsetLeft ?? 0
const viewportTop = visualViewport?.offsetTop ?? 0
const viewportW =
visualViewport?.width ??
(typeof window !== 'undefined' ? window.innerWidth : 390)
const viewportH =
visualViewport?.height ??
(typeof window !== 'undefined' ? window.innerHeight : 800)
const isCoarsePointer =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
const isTouchLike = isCoarsePointer || viewportW < 640
const baseSize = isTouchLike ? 136 : 156
const largeBoxSize = isTouchLike
? Math.min(176, Math.max(152, viewportW - 32))
: 190
const padding = isTouchLike ? 16 : 20
const activeBox =
drawingBox ||
@ -5332,29 +5357,70 @@ export default function TrainingTab(props: {
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
const boxNeedsLargeMagnifier =
hasUsableBox &&
(
boxPixelW > baseSize - padding * 2 ||
boxPixelH > baseSize - padding * 2
)
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
const fitPadding = isTouchLike ? 14 : 18
const fitZoom = hasUsableBox
? Math.min(
(size - padding * 2) / Math.max(1, boxPixelW),
(size - padding * 2) / Math.max(1, boxPixelH)
(size - fitPadding * 2) / Math.max(1, boxPixelW),
(size - fitPadding * 2) / Math.max(1, boxPixelH)
)
: 2
const zoom = hasUsableBox
? Math.max(0.55, Math.min(2.25, fitZoom))
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
: 2
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
const gap = isTouchLike ? 10 : 12
const edgeGap = isTouchLike ? 8 : 10
let left = touchMagnifier.clientX - size / 2
let top = touchMagnifier.clientY - size - 28
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
// Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
const anchorX = hasUsableBox
? rect.left + boxCenterX * rect.width
: rect.left + touchMagnifier.imageX * rect.width
if (top < 8) {
top = touchMagnifier.clientY + 28
const anchorTop = hasUsableBox
? rect.top + activeBox.y * rect.height
: rect.top + touchMagnifier.imageY * rect.height
const anchorBottom = hasUsableBox
? rect.top + (activeBox.y + activeBox.h) * rect.height
: rect.top + touchMagnifier.imageY * rect.height
let left = anchorX - size / 2
// Normalfall: Lupe über der Box.
let top = anchorTop - size - gap
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
if (top < viewportTop + edgeGap) {
top = anchorBottom + gap
}
left = Math.max(8, Math.min(viewportW - size - 8, left))
top = Math.max(8, Math.min(viewportH - size - 8, top))
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
// an das untere Ende des sichtbaren Viewports klemmen.
if (top + size > viewportTop + viewportH - edgeGap) {
top = viewportTop + viewportH - size - edgeGap
}
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
if (top < viewportTop + edgeGap) {
top = viewportTop + edgeGap
}
left = Math.max(
viewportLeft + edgeGap,
Math.min(viewportLeft + viewportW - size - edgeGap, left)
)
const imageWidth = rect.width * zoom
const imageHeight = rect.height * zoom
@ -5548,15 +5614,6 @@ export default function TrainingTab(props: {
</Button>
</div>
</div>
<div
className={[
'mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400',
imageExpanded ? 'lg:hidden' : 'lg:block',
].join(' ')}
>
Prüfe das Bild. Wenn die Erkennung stimmt: Passt so. Wenn nicht: korrigieren und speichern.
</div>
</section>
{trainingRunning ? (