This commit is contained in:
Chris 2026-03-31 19:24:19 +02:00
parent 3052836f3b
commit df8e4f747f
9 changed files with 237 additions and 204 deletions

View File

@ -206,7 +206,7 @@ func (d *dummyResponseWriter) WriteHeader(statusCode int) {}
var ( var (
jobs = map[string]*RecordJob{} jobs = map[string]*RecordJob{}
jobsMu = sync.Mutex{} jobsMu = sync.RWMutex{}
) )
func startPreviewIdleKiller() { func startPreviewIdleKiller() {
@ -214,21 +214,21 @@ func startPreviewIdleKiller() {
go func() { go func() {
defer t.Stop() defer t.Stop()
for range t.C { for range t.C {
jobsMu.Lock() jobsMu.RLock()
list := make([]*RecordJob, 0, len(jobs)) list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs { for _, j := range jobs {
if j != nil { if j != nil {
list = append(list, j) list = append(list, j)
} }
} }
jobsMu.Unlock() jobsMu.RUnlock()
for _, j := range list { for _, j := range list {
jobsMu.Lock() jobsMu.RLock()
cmdRunning := j.previewCmd != nil cmdRunning := j.previewCmd != nil
last := j.previewLastHit last := j.previewLastHit
st := j.Status st := j.Status
jobsMu.Unlock() jobsMu.RUnlock()
if !cmdRunning { if !cmdRunning {
continue continue

View File

@ -1068,20 +1068,70 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
// ---------------- Handlers ---------------- // ---------------- Handlers ----------------
type recordListItem struct {
ID string `json:"id"`
SourceURL string `json:"sourceUrl"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
StartedAtMs int64 `json:"startedAtMs"`
EndedAt *time.Time `json:"endedAt,omitempty"`
EndedAtMs int64 `json:"endedAtMs,omitempty"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
Phase string `json:"phase,omitempty"`
Progress int `json:"progress,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
PreviewState string `json:"previewState,omitempty"`
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ModelImageURL string `json:"modelImageUrl,omitempty"`
ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"`
PostWorkKey string `json:"postWorkKey,omitempty"`
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
}
func snapshotRecordJobForList(j *RecordJob) recordListItem {
var postWorkCopy *PostWorkKeyStatus
if j.PostWork != nil {
pw := *j.PostWork
postWorkCopy = &pw
}
return recordListItem{
ID: j.ID,
SourceURL: j.SourceURL,
Status: string(j.Status),
StartedAt: j.StartedAt,
StartedAtMs: j.StartedAtMs,
EndedAt: j.EndedAt,
EndedAtMs: j.EndedAtMs,
Output: j.Output,
Error: j.Error,
Phase: j.Phase,
Progress: j.Progress,
SizeBytes: j.SizeBytes,
DurationSeconds: j.DurationSeconds,
PreviewState: j.PreviewState,
PostWorkKey: j.PostWorkKey,
PostWork: postWorkCopy,
}
}
func recordList(w http.ResponseWriter, r *http.Request) { func recordList(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) { if !mustMethod(w, r, http.MethodGet) {
return return
} }
jobsMu.Lock() jobsMu.RLock()
list := make([]*RecordJob, 0, len(jobs)) list := make([]recordListItem, 0, len(jobs))
for _, j := range jobs { for _, j := range jobs {
if j == nil || j.Hidden { if j == nil || j.Hidden {
continue continue
} }
list = append(list, j) list = append(list, snapshotRecordJobForList(j))
} }
jobsMu.Unlock() jobsMu.RUnlock()
sort.Slice(list, func(i, j int) bool { sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt) return list[i].StartedAt.After(list[j].StartedAt)
@ -1267,9 +1317,9 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
return return
} }
jobsMu.Lock() jobsMu.RLock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.RUnlock()
if !ok || job == nil { if !ok || job == nil {
http.Error(w, "job nicht gefunden", http.StatusNotFound) http.Error(w, "job nicht gefunden", http.StatusNotFound)
return return
@ -1411,9 +1461,9 @@ func recordStatus(w http.ResponseWriter, r *http.Request) {
return return
} }
jobsMu.Lock() jobsMu.RLock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.RUnlock()
if !ok { if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound) http.Error(w, "job nicht gefunden", http.StatusNotFound)
@ -1431,9 +1481,9 @@ func recordStop(w http.ResponseWriter, r *http.Request) {
id := q(r, "id") id := q(r, "id")
jobsMu.Lock() jobsMu.RLock()
job, ok := jobs[id] job, ok := jobs[id]
jobsMu.Unlock() jobsMu.RUnlock()
if !ok { if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound) http.Error(w, "job nicht gefunden", http.StatusNotFound)
return return
@ -1754,6 +1804,33 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
// ---------------- Done meta + list ---------------- // ---------------- Done meta + list ----------------
func activePostworkBasenameSet() map[string]struct{} {
out := make(map[string]struct{})
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isPostworkJob(j) {
continue
}
if isTerminalJobStatus(j.Status) {
continue
}
base := strings.TrimSpace(filepath.Base(j.Output))
if base == "" {
continue
}
out[strings.ToLower(base)] = struct{}{}
}
return out
}
func recordDoneMeta(w http.ResponseWriter, r *http.Request) { func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) { if !mustMethod(w, r, http.MethodGet) {
return return
@ -1884,34 +1961,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
sortedAll := doneCache.sortedIdx sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock() doneCache.mu.Unlock()
isActivePostworkOutput := func(fullPath string) bool { activePostwork := activePostworkBasenameSet()
base := strings.TrimSpace(filepath.Base(fullPath))
if base == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isPostworkJob(j) {
continue
}
if isTerminalJobStatus(j.Status) {
continue
}
if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) {
return true
}
}
return false
}
count := 0 count := 0
if qModel == "" { if qModel == "" {
@ -1922,7 +1972,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
for _, idx := range sortedAll[incKey+"|completed_desc"] { for _, idx := range sortedAll[incKey+"|completed_desc"] {
it := items[idx] it := items[idx]
if isActivePostworkOutput(it.job.Output) { if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
continue continue
} }
count++ count++
@ -1935,7 +1985,7 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if it.modelKey != qModel { if it.modelKey != qModel {
continue continue
} }
if isActivePostworkOutput(it.job.Output) { if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
continue continue
} }
count++ count++
@ -2157,34 +2207,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
}) })
} }
isActivePostworkOutput := func(fullPath string) bool { activePostwork := activePostworkBasenameSet()
base := strings.TrimSpace(filepath.Base(fullPath))
if base == "" {
return false
}
jobsMu.Lock()
defer jobsMu.Unlock()
for _, j := range jobs {
if j == nil {
continue
}
if !isPostworkJob(j) {
continue
}
if isTerminalJobStatus(j.Status) {
continue
}
if strings.EqualFold(filepath.Base(strings.TrimSpace(j.Output)), base) {
return true
}
}
return false
}
filteredIdx := make([]int, 0, len(idx)) filteredIdx := make([]int, 0, len(idx))
for _, ii := range idx { for _, ii := range idx {
@ -2192,7 +2215,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
if it.job == nil { if it.job == nil {
continue continue
} }
if isActivePostworkOutput(it.job.Output) { if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
continue continue
} }
filteredIdx = append(filteredIdx, ii) filteredIdx = append(filteredIdx, ii)

View File

@ -54,6 +54,9 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
jobsMu.Lock() jobsMu.Lock()
defer jobsMu.Unlock() defer jobsMu.Unlock()
oldPhase := job.Phase
oldProgress := job.Progress
inPostwork := job.EndedAt != nil || (strings.TrimSpace(job.Phase) != "" && strings.ToLower(strings.TrimSpace(job.Phase)) != "recording") inPostwork := job.EndedAt != nil || (strings.TrimSpace(job.Phase) != "" && strings.ToLower(strings.TrimSpace(job.Phase)) != "recording")
if inPostwork { if inPostwork {
if phaseLower == "" || phaseLower == "recording" { if phaseLower == "" || phaseLower == "recording" {
@ -61,20 +64,19 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
} }
} }
newPhase := job.Phase
if phase != "" { if phase != "" {
job.Phase = phase newPhase = phase
} }
// recording = direkter Prozentwert newProgress := job.Progress
if !inPostwork { if !inPostwork {
if pct < job.Progress { if pct < newProgress {
pct = job.Progress pct = newProgress
} }
job.Progress = pct newProgress = pct
return } else {
}
// postwork-Phasen: pct ist IMMER lokal 0..100 innerhalb der Phase
r := rangeFor(phaseLower) r := rangeFor(phaseLower)
width := float64(r.end - r.start) width := float64(r.end - r.start)
@ -89,12 +91,18 @@ func setJobProgress(job *RecordJob, phase string, pct int) {
if mapped > r.end { if mapped > r.end {
mapped = r.end mapped = r.end
} }
if mapped < newProgress {
if mapped < job.Progress { mapped = newProgress
mapped = job.Progress }
newProgress = mapped
} }
job.Progress = mapped if oldPhase == newPhase && oldProgress == newProgress {
return
}
job.Phase = newPhase
job.Progress = newProgress
} }
// ---------------- Preview scrubber ---------------- // ---------------- Preview scrubber ----------------

View File

@ -1,13 +1,13 @@
{ {
"databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable",
"encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=",
"recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records",
"doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done",
"ffmpegPath": "", "ffmpegPath": "",
"autoAddToDownloadList": true, "autoAddToDownloadList": true,
"autoStartAddedDownloads": true, "autoStartAddedDownloads": true,
"enableConcurrentDownloadsLimit": true, "enableConcurrentDownloadsLimit": true,
"maxConcurrentDownloads": 40, "maxConcurrentDownloads": 80,
"useChaturbateApi": true, "useChaturbateApi": true,
"useMyFreeCamsWatcher": true, "useMyFreeCamsWatcher": true,
"autoDeleteSmallDownloads": true, "autoDeleteSmallDownloads": true,
@ -17,5 +17,5 @@
"teaserPlayback": "all", "teaserPlayback": "all",
"teaserAudio": true, "teaserAudio": true,
"enableNotifications": true, "enableNotifications": true,
"encryptedCookies": "tZzqhwM+6rrdV36edfxpkhfikSfzfs4VZESLJIpYw1U4Bwn7vo+P8Sq86M7nvXpvbQVg00QLl5Wo2dD2agojU58GYY5P5P6Wpc1ZPr2T409BaQXRA/WhHiZv7z/b4JoVYX7fScZ4FDtscQYuTdMpnU6Wr725j66Qg2x8VgXqUHb1qceUes9P3L2kc8MAvZHWnZHIEjbgDW9NAZOADlbBxOonmc5hMGpz5xzmpWWV7Gqg6n1i75U/XBDV6Ks1Jn5Ic9dwi0f1BH6zo56v5oDdPAxKPlw01TUsV9ZAA+u+TjWUiq52AJP/C20bujUEsmQupqKxMs3E4rs6DEb7SYfbQ6SSryKyYYLCv0T3+CbeUgDmgYd2n+yjatE8udHvmkCtZCWvBIK4lSglsVeFq1prgpiGBL2ItyLcR13musHGbZ7Whq/p1btJsFDTTyfGnILW+sRUcd1Y/17YM5zKAWYyi81qKop6DYqh9kBoM2y3DYVxsLkb9+lUmwN6N7oopqh/bJ8vxJKyC74=" "encryptedCookies": "yDldmKNsPH20hdVRPLdr8nVj4CoIC/NHiXESzcqLLSsEcgi4P7V7+xcKw2d3I/GnGR6uVNQYBrc4c+YeUs9J7wbn9Ir+BdaFDAWZVWBoaHgdcgYLaKUxIMO273zfi+jwkPF4DHuSBw9gkAHgKeF15H+azEQEDjJc8VH1C+7/F6qhkHBtFKKDiAkj8a4+cqmzr6Cr+tdraCaTxu4YZmqA/3erMRl/HA6egQFAcfGB2KAcfBqQagvXZ+R8SgfTwJiLG7RkH5Xdh3fT34Cc03sbyXCJxMzFUd4RKgCRyM2oUfqXq/oOL8AbZxm0oi2Mh+8TaqbAfdu1CstnPlAFAk7s3gMvp4Ni5QVwtfKQYzSYThKyaZSV2wnWZIWOXh9Zb3SvmAQBANJUHG9WUr/nyxqDPj03bIKNWA8/ZflAyVtCQtlpQU4Nrqe3Fhl/ALBuhgTcwx8X77rs1Py7sR9SJ8Ks+bKf8ziE04oQem6HQsIf1TAZTpYbpd7mD7NrbUuDtbJ3RabZMppygQc="
} }

View File

@ -2124,7 +2124,7 @@ export default function App() {
} }
}, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow]) }, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow])
// ✅ settings: nur einmal laden + nach Save-Event + optional bei focus/visibility // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -2138,19 +2138,23 @@ export default function App() {
} }
const onUpdated = () => void load() const onUpdated = () => void load()
const onFocus = () => void load()
let wasHidden = document.hidden
const onVisible = () => {
const becameVisible = wasHidden && !document.hidden
wasHidden = document.hidden
if (becameVisible) void load()
}
window.addEventListener('recorder-settings-updated', onUpdated as EventListener) window.addEventListener('recorder-settings-updated', onUpdated as EventListener)
window.addEventListener('focus', onFocus) document.addEventListener('visibilitychange', onVisible)
document.addEventListener('visibilitychange', onFocus)
load() void load()
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener('recorder-settings-updated', onUpdated as EventListener) window.removeEventListener('recorder-settings-updated', onUpdated as EventListener)
window.removeEventListener('focus', onFocus) document.removeEventListener('visibilitychange', onVisible)
document.removeEventListener('visibilitychange', onFocus)
} }
}, []) }, [])
@ -2313,37 +2317,6 @@ export default function App() {
localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies)) localStorage.setItem(COOKIE_STORAGE_KEY, JSON.stringify(cookies))
}, [cookies, cookiesLoaded]) }, [cookies, cookiesLoaded])
useEffect(() => {
// 1x initial / bei sort-wechsel (für Badge)
void loadDoneCount()
const onVis = () => {
if (!document.hidden) void loadDoneCount()
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [loadDoneCount])
useEffect(() => {
if (selectedTab !== 'finished') return
void loadDoneCount()
const onVis = () => {
if (!document.hidden) {
void loadDoneCount()
}
}
document.addEventListener('visibilitychange', onVis)
return () => {
document.removeEventListener('visibilitychange', onVis)
}
}, [selectedTab, loadDoneCount])
useEffect(() => { useEffect(() => {
if (!authed) return if (!authed) return

View File

@ -18,6 +18,7 @@ import DownloadsCardRow, {
} from './DownloadsCardRow' } from './DownloadsCardRow'
import SwipeActionRow from './SwipeActionRow' import SwipeActionRow from './SwipeActionRow'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import LazyMountWhenVisible from './LazyMountWhenVisible'
type AutostartState = { type AutostartState = {
paused?: boolean paused?: boolean
@ -564,46 +565,6 @@ function DiskEmergencyBadge() {
) )
} }
function LazyMountWhenVisible({
children,
placeholder,
rootMargin = '300px',
}: {
children: ReactNode
placeholder: ReactNode
rootMargin?: string
}) {
const hostRef = useRef<HTMLDivElement | null>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (visible) return
const el = hostRef.current
if (!el) return
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting) {
setVisible(true)
io.disconnect()
}
},
{ rootMargin, threshold: 0.01 }
)
io.observe(el)
return () => io.disconnect()
}, [visible, rootMargin])
return (
<div ref={hostRef} className="w-full h-full">
{visible ? children : placeholder}
</div>
)
}
export default function Downloads({ export default function Downloads({
jobs, jobs,
pending = [], pending = [],
@ -994,9 +955,8 @@ export default function Downloads({
return ( return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md"> <div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<LazyMountWhenVisible <LazyMountWhenVisible
placeholder={ className="w-full h-full"
<div className="h-full w-full bg-gray-100 dark:bg-white/10" /> placeholder={<div className="h-full w-full bg-gray-100 dark:bg-white/10" />}
}
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}

View File

@ -2,11 +2,13 @@
'use client' 'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import Button from './Button' import Button from './Button'
import ModelPreview from './ModelPreview' import ModelPreview from './ModelPreview'
import ProgressBar from './ProgressBar' import ProgressBar from './ProgressBar'
import RecordJobActions from './RecordJobActions' import RecordJobActions from './RecordJobActions'
import type { RecordJob } from '../../types' import type { RecordJob } from '../../types'
import LazyMountWhenVisible from './LazyMountWhenVisible'
export type PendingWatchedRoom = WaitingModelRow & { export type PendingWatchedRoom = WaitingModelRow & {
currentShow: string currentShow: string
@ -703,6 +705,10 @@ export default function DownloadsCardRow({
bg-gray-100 ring-1 ring-black/5 bg-gray-100 ring-1 ring-black/5
dark:bg-white/10 dark:ring-white/10 dark:bg-white/10 dark:ring-white/10
" "
>
<LazyMountWhenVisible
className="w-full h-full"
placeholder={<div className="h-full w-full bg-gray-100 dark:bg-white/10" />}
> >
<ModelPreview <ModelPreview
jobId={j.id} jobId={j.id}
@ -722,6 +728,7 @@ export default function DownloadsCardRow({
thumbsCandidates={jobThumbsJPGCandidates(j)} thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full" className="w-full h-full"
/> />
</LazyMountWhenVisible>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@ -814,7 +814,13 @@ export default function FinishedVideoPreview({
teaserOk && teaserOk &&
Boolean(teaserSrc) && Boolean(teaserSrc) &&
!showingInlineVideo && !showingInlineVideo &&
shouldPreloadAnimatedAssets shouldPreloadAnimatedAssets &&
animatedTrigger !== 'hover'
const showStillImage =
shouldLoadStill &&
!showingInlineVideo &&
!(animatedMode === 'teaser' && teaserActive)
// ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig) // ✅ Progress-Quelle: NUR das Element, das wirklich spielt (für "Sprünge" wichtig)
const progressVideoRef = const progressVideoRef =
@ -1298,7 +1304,7 @@ export default function FinishedVideoPreview({
data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined} data-fps={typeof effectiveFPS === 'number' ? String(effectiveFPS) : undefined}
> >
{/* ✅ Thumb IMMER als Fallback/Background */} {/* ✅ Thumb IMMER als Fallback/Background */}
{shouldLoadStill && thumbSrc && thumbOk ? ( {showStillImage && thumbSrc && thumbOk ? (
<img <img
src={thumbSrc} src={thumbSrc}
loading={alwaysLoadStill ? 'eager' : 'lazy'} loading={alwaysLoadStill ? 'eager' : 'lazy'}

View File

@ -0,0 +1,56 @@
// frontend\src\components\ui\LazyMountWhenVisible.tsx
'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
type Props = {
children: ReactNode
placeholder?: ReactNode
rootMargin?: string
threshold?: number
className?: string
once?: boolean
}
export default function LazyMountWhenVisible({
children,
placeholder = null,
rootMargin = '300px',
threshold = 0.01,
className,
once = true,
}: Props) {
const hostRef = useRef<HTMLDivElement | null>(null)
const [visible, setVisible] = useState(false)
useEffect(() => {
const el = hostRef.current
if (!el) return
if (visible && once) return
const io = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (!entry) return
if (entry.isIntersecting) {
setVisible(true)
if (once) io.disconnect()
} else if (!once) {
setVisible(false)
}
},
{ rootMargin, threshold }
)
io.observe(el)
return () => io.disconnect()
}, [visible, once, rootMargin, threshold])
return (
<div ref={hostRef} className={className}>
{visible ? children : placeholder}
</div>
)
}