From 19b473f8bd542712611135b9a9fbf92be721e087 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:42:14 +0100 Subject: [PATCH] updated --- backend/main.go | 417 +----------------- backend/performance.go | 343 ++++++++++++++ frontend/src/App.tsx | 4 +- frontend/src/components/ui/Checkbox.tsx | 2 +- .../src/components/ui/FinishedDownloads.tsx | 72 ++- .../ui/FinishedDownloadsCardsView.tsx | 2 +- .../ui/FinishedDownloadsGalleryView.tsx | 2 +- 7 files changed, 378 insertions(+), 464 deletions(-) create mode 100644 backend/performance.go diff --git a/backend/main.go b/backend/main.go index 1024bc4..2ea6294 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,22 +11,17 @@ import ( "errors" "fmt" "io" - "math" "net/http" "os" "os/exec" "path/filepath" "regexp" - "runtime" "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/grafov/m3u8" - gocpu "github.com/shirou/gopsutil/v3/cpu" - godisk "github.com/shirou/gopsutil/v3/disk" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) @@ -213,13 +208,6 @@ var ( jobsMu = sync.Mutex{} ) -var serverStartedAt = time.Now() - -var lastCPUUsageBits uint64 // atomic float64 bits - -func setLastCPUUsage(v float64) { atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) } -func getLastCPUUsage() float64 { return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) } - func startPreviewIdleKiller() { t := time.NewTicker(5 * time.Second) go func() { @@ -254,8 +242,8 @@ func startPreviewIdleKiller() { } func init() { - initFFmpegSemaphores() - startAdaptiveSemController(context.Background()) + initFFmpegLimiters() + startCPUUsageSampler(context.Background()) startPreviewIdleKiller() initSSE() @@ -328,228 +316,6 @@ func detectFFprobePath() string { return "ffprobe" } -// ---------- Dynamic Semaphore (resizeable by load controller) ---------- - -type DynSem struct { - mu sync.Mutex - in int - max int - cap int -} - -func NewDynSem(initial, cap int) *DynSem { - if cap < 1 { - cap = 1 - } - if initial < 1 { - initial = 1 - } - if initial > cap { - initial = cap - } - return &DynSem{max: initial, cap: cap} -} - -func (s *DynSem) Acquire(ctx context.Context) error { - for { - if ctx != nil && ctx.Err() != nil { - return ctx.Err() - } - s.mu.Lock() - if s.in < s.max { - s.in++ - s.mu.Unlock() - return nil - } - s.mu.Unlock() - time.Sleep(25 * time.Millisecond) - } -} - -func (s *DynSem) Release() { - s.mu.Lock() - if s.in > 0 { - s.in-- - } - s.mu.Unlock() -} - -func (s *DynSem) SetMax(n int) { - if n < 1 { - n = 1 - } - if n > s.cap { - n = s.cap - } - s.mu.Lock() - s.max = n - s.mu.Unlock() -} - -func (s *DynSem) Max() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.max -} - -func (s *DynSem) Cap() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.cap -} - -func (s *DynSem) InUse() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.in -} - -var ( - genSem *DynSem - previewSem *DynSem - thumbSem *DynSem - durSem *DynSem -) - -func clamp(n, lo, hi int) int { - if n < lo { - return lo - } - if n > hi { - return hi - } - return n -} - -func envInt(name string) (int, bool) { - v := strings.TrimSpace(os.Getenv(name)) - if v == "" { - return 0, false - } - n, err := strconv.Atoi(v) - if err != nil { - return 0, false - } - return n, true -} - -func initFFmpegSemaphores() { - cpu := runtime.NumCPU() - if cpu <= 0 { - cpu = 2 - } - - // Defaults (heuristisch) - previewN := clamp((cpu+1)/2, 1, 6) // x264 live -> konservativ - thumbN := clamp(cpu, 2, 12) // Frames -> darf höher - genN := clamp((cpu+3)/4, 1, 4) // preview.mp4 clips -> eher klein - durN := clamp(cpu, 2, 16) // ffprobe: darf höher, aber nicht unbegrenzt - - // ENV Overrides (optional) - if n, ok := envInt("PREVIEW_WORKERS"); ok { - previewN = clamp(n, 1, 32) - } - if n, ok := envInt("THUMB_WORKERS"); ok { - thumbN = clamp(n, 1, 64) - } - if n, ok := envInt("GEN_WORKERS"); ok { - genN = clamp(n, 1, 16) - } - if n, ok := envInt("DUR_WORKERS"); ok { - durN = clamp(n, 1, 64) - } - - // Caps (Obergrenzen) – können via ENV überschrieben werden - previewCap := clamp(cpu, 2, 12) - thumbCap := clamp(cpu*2, 4, 32) - genCap := clamp((cpu+1)/2, 2, 12) - durCap := clamp(cpu*2, 4, 32) - - if n, ok := envInt("PREVIEW_CAP"); ok { - previewCap = clamp(n, 1, 64) - } - if n, ok := envInt("THUMB_CAP"); ok { - thumbCap = clamp(n, 1, 128) - } - if n, ok := envInt("GEN_CAP"); ok { - genCap = clamp(n, 1, 64) - } - if n, ok := envInt("DUR_CAP"); ok { - durCap = clamp(n, 1, 128) - } - - // Initial max (Startwerte) - previewSem = NewDynSem(previewN, previewCap) - thumbSem = NewDynSem(thumbN, thumbCap) - genSem = NewDynSem(genN, genCap) - durSem = NewDynSem(durN, durCap) - - /* - fmt.Printf( - "🔧 semaphores(init): preview=%d/%d thumb=%d/%d gen=%d/%d dur=%d/%d (cpu=%d)\n", - previewSem.Max(), previewSem.Cap(), - thumbSem.Max(), thumbSem.Cap(), - genSem.Max(), genSem.Cap(), - durSem.Max(), durSem.Cap(), - cpu, - ) - - fmt.Printf( - "🔧 semaphores: preview=%d thumb=%d gen=%d dur=%d (cpu=%d)\n", - previewN, thumbN, genN, durN, cpu, - ) - */ -} - -func startAdaptiveSemController(ctx context.Context) { - targetHi := 85.0 - targetLo := 65.0 - - if v := strings.TrimSpace(os.Getenv("CPU_TARGET_HI")); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - targetHi = f - } - } - if v := strings.TrimSpace(os.Getenv("CPU_TARGET_LO")); v != "" { - if f, err := strconv.ParseFloat(v, 64); err == nil { - targetLo = f - } - } - - // Warmup (erste Messung kann 0 sein) - _, _ = gocpu.Percent(200*time.Millisecond, false) - - t := time.NewTicker(2 * time.Second) - go func() { - defer t.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-t.C: - p, err := gocpu.Percent(0, false) - if err != nil || len(p) == 0 { - continue - } - usage := p[0] - setLastCPUUsage(usage) - - // Preview ist am teuersten → konservativ - if usage > targetHi { - previewSem.SetMax(previewSem.Max() - 1) - genSem.SetMax(genSem.Max() - 1) - thumbSem.SetMax(thumbSem.Max() - 1) - } else if usage < targetLo { - previewSem.SetMax(previewSem.Max() + 1) - genSem.SetMax(genSem.Max() + 1) - thumbSem.SetMax(thumbSem.Max() + 1) - } - } - } - }() -} - type durEntry struct { size int64 mod time.Time @@ -565,185 +331,6 @@ var startedAtFromFilenameRe = regexp.MustCompile( `^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`, ) -func buildPerfSnapshot() map[string]any { - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - - s := getSettings() - recordDir, _ := resolvePathRelativeToApp(s.RecordDir) - - var diskFreeBytes uint64 - var diskTotalBytes uint64 - var diskUsedPercent float64 - diskPath := recordDir - - if recordDir != "" { - if u, err := godisk.Usage(recordDir); err == nil && u != nil { - diskFreeBytes = u.Free - diskTotalBytes = u.Total - diskUsedPercent = u.UsedPercent - } - } - - // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) - pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() - - resp := map[string]any{ - "ts": time.Now().UTC().Format(time.RFC3339Nano), - "serverMs": time.Now().UTC().UnixMilli(), // ✅ für "Ping" im Frontend (Approx) - "uptimeSec": time.Since(serverStartedAt).Seconds(), - "cpuPercent": func() float64 { - v := getLastCPUUsage() - if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { - return 0 - } - return v - }(), - "diskPath": diskPath, - "diskFreeBytes": diskFreeBytes, - "diskTotalBytes": diskTotalBytes, - "diskUsedPercent": diskUsedPercent, - "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, - - // ✅ statt LowDiskPauseBelowGB aus Settings - "diskPauseBelowGB": pauseGB, - "diskResumeAboveGB": resumeGB, - - // ✅ optional, aber sehr hilfreich (Debug/UI) - "diskInFlightBytes": inFlight, - "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), - "diskPauseNeedBytes": pauseNeed, - "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), - "diskResumeNeedBytes": resumeNeed, - "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), - - "goroutines": runtime.NumGoroutine(), - "mem": map[string]any{ - "alloc": ms.Alloc, - "heapAlloc": ms.HeapAlloc, - "heapInuse": ms.HeapInuse, - "sys": ms.Sys, - "numGC": ms.NumGC, - }, - } - - sem := map[string]any{} - if genSem != nil { - sem["gen"] = map[string]any{"inUse": genSem.InUse(), "cap": genSem.Cap(), "max": genSem.Max()} - } - if previewSem != nil { - sem["preview"] = map[string]any{"inUse": previewSem.InUse(), "cap": previewSem.Cap(), "max": previewSem.Max()} - } - if thumbSem != nil { - sem["thumb"] = map[string]any{"inUse": thumbSem.InUse(), "cap": thumbSem.Cap(), "max": thumbSem.Max()} - } - if durSem != nil { - sem["dur"] = map[string]any{"inUse": durSem.InUse(), "cap": durSem.Cap(), "max": durSem.Max()} - } - if len(sem) > 0 { - resp["sem"] = sem - } - - return resp -} - -func pingHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(http.StatusNoContent) -} - -func perfHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) - return - } - - resp := buildPerfSnapshot() - - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Cache-Control", "no-store") - _ = json.NewEncoder(w).Encode(resp) -} - -func perfStreamHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) - return - } - - fl, ok := w.(http.Flusher) - if !ok { - http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) - return - } - - // Optional: client kann Intervall mitgeben: /api/perf/stream?ms=5000 - ms := 5000 - if q := r.URL.Query().Get("ms"); q != "" { - if v, err := strconv.Atoi(q); err == nil { - // clamp: 1000..30000 - if v < 1000 { - v = 1000 - } - if v > 30000 { - v = 30000 - } - ms = v - } - } - - w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("Connection", "keep-alive") - // hilfreich hinter nginx/proxies: - w.Header().Set("X-Accel-Buffering", "no") - - ctx := r.Context() - - // sofort erstes Event schicken - send := func() error { - payload := buildPerfSnapshot() - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(payload); err != nil { - return err - } - - // event: perf - _, _ = io.WriteString(w, "event: perf\n") - _, _ = io.WriteString(w, "data: ") - _, _ = w.Write(buf.Bytes()) - _, _ = io.WriteString(w, "\n") - fl.Flush() - return nil - } - - // initial - _ = send() - - t := time.NewTicker(time.Duration(ms) * time.Millisecond) - hb := time.NewTicker(15 * time.Second) // heartbeat gegen Proxy timeouts - defer t.Stop() - defer hb.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-t.C: - _ = send() - case <-hb.C: - // SSE Kommentar als Heartbeat - _, _ = io.WriteString(w, ": keep-alive\n\n") - fl.Flush() - } - } -} - func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) { // returns: (shouldDelete, sizeBytes, thresholdBytes) diff --git a/backend/performance.go b/backend/performance.go new file mode 100644 index 0000000..0532056 --- /dev/null +++ b/backend/performance.go @@ -0,0 +1,343 @@ +// backend\performance.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "math" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" + + gocpu "github.com/shirou/gopsutil/v3/cpu" + godisk "github.com/shirou/gopsutil/v3/disk" +) + +// Server-Startzeit für Perf/Uptime +var serverStartedAt = time.Now() + +var lastCPUUsageBits uint64 // atomic float64 bits + +func setLastCPUUsage(v float64) { + atomic.StoreUint64(&lastCPUUsageBits, math.Float64bits(v)) +} + +func getLastCPUUsage() float64 { + return math.Float64frombits(atomic.LoadUint64(&lastCPUUsageBits)) +} + +func startCPUUsageSampler(ctx context.Context) { + _, _ = gocpu.Percent(200*time.Millisecond, false) + + t := time.NewTicker(2 * time.Second) + + go func() { + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + p, err := gocpu.Percent(0, false) + if err != nil || len(p) == 0 { + continue + } + + usage := p[0] + if math.IsNaN(usage) || math.IsInf(usage, 0) || usage < 0 { + continue + } + if usage > 100 { + usage = 100 + } + + setLastCPUUsage(usage) + } + } + }() +} + +// ---------- Feste ffmpeg/ffprobe Limiter ---------- + +type FixedLimiter chan struct{} + +func NewFixedLimiter(n int) FixedLimiter { + if n < 1 { + n = 1 + } + return make(FixedLimiter, n) +} + +func (l FixedLimiter) Acquire(ctx context.Context) error { + if l == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + select { + case l <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (l FixedLimiter) Release() { + if l == nil { + return + } + select { + case <-l: + default: + } +} + +func (l FixedLimiter) Limit() int { + return cap(l) +} + +func (l FixedLimiter) InUse() int { + return len(l) +} + +var ( + previewSem FixedLimiter + thumbSem FixedLimiter + genSem FixedLimiter + durSem FixedLimiter +) + +func envInt(name string) (int, bool) { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return 0, false + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, false + } + return n, true +} + +func initFFmpegLimiters() { + previewN := 2 + thumbN := 4 + genN := 2 + durN := 8 + + if n, ok := envInt("PREVIEW_WORKERS"); ok && n > 0 { + previewN = n + } + if n, ok := envInt("THUMB_WORKERS"); ok && n > 0 { + thumbN = n + } + if n, ok := envInt("GEN_WORKERS"); ok && n > 0 { + genN = n + } + if n, ok := envInt("DUR_WORKERS"); ok && n > 0 { + durN = n + } + + previewSem = NewFixedLimiter(previewN) + thumbSem = NewFixedLimiter(thumbN) + genSem = NewFixedLimiter(genN) + durSem = NewFixedLimiter(durN) + + setLastCPUUsage(0) + startCPUUsageSampler(context.Background()) +} + +func buildPerfSnapshot() map[string]any { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + s := getSettings() + recordDir, _ := resolvePathRelativeToApp(s.RecordDir) + + var diskFreeBytes uint64 + var diskTotalBytes uint64 + var diskUsedPercent float64 + diskPath := recordDir + + if recordDir != "" { + if u, err := godisk.Usage(recordDir); err == nil && u != nil { + diskFreeBytes = u.Free + diskTotalBytes = u.Total + diskUsedPercent = u.UsedPercent + } + } + + // ✅ Dynamische Disk-Schwellen (2× inFlight, Resume = +3GB) + pauseGB, resumeGB, inFlight, pauseNeed, resumeNeed := computeDiskThresholds() + + resp := map[string]any{ + "ts": time.Now().UTC().Format(time.RFC3339Nano), + "serverMs": time.Now().UTC().UnixMilli(), + "uptimeSec": time.Since(serverStartedAt).Seconds(), + "cpuPercent": func() float64 { + v := getLastCPUUsage() + if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { + return 0 + } + if v > 100 { + return 100 + } + return v + }(), + "diskPath": diskPath, + "diskFreeBytes": diskFreeBytes, + "diskTotalBytes": diskTotalBytes, + "diskUsedPercent": diskUsedPercent, + "diskEmergency": atomic.LoadInt32(&diskEmergency) == 1, + + "diskPauseBelowGB": pauseGB, + "diskResumeAboveGB": resumeGB, + + "diskInFlightBytes": inFlight, + "diskInFlightHuman": formatBytesSI(u64ToI64(inFlight)), + "diskPauseNeedBytes": pauseNeed, + "diskPauseNeedHuman": formatBytesSI(u64ToI64(pauseNeed)), + "diskResumeNeedBytes": resumeNeed, + "diskResumeNeedHuman": formatBytesSI(u64ToI64(resumeNeed)), + + "goroutines": runtime.NumGoroutine(), + "mem": map[string]any{ + "alloc": ms.Alloc, + "heapAlloc": ms.HeapAlloc, + "heapInuse": ms.HeapInuse, + "sys": ms.Sys, + "numGC": ms.NumGC, + }, + } + + sem := map[string]any{} + if previewSem != nil { + sem["preview"] = map[string]any{ + "inUse": previewSem.InUse(), + "limit": previewSem.Limit(), + } + } + if thumbSem != nil { + sem["thumb"] = map[string]any{ + "inUse": thumbSem.InUse(), + "limit": thumbSem.Limit(), + } + } + if genSem != nil { + sem["gen"] = map[string]any{ + "inUse": genSem.InUse(), + "limit": genSem.Limit(), + } + } + if durSem != nil { + sem["dur"] = map[string]any{ + "inUse": durSem.InUse(), + "limit": durSem.Limit(), + } + } + if len(sem) > 0 { + resp["sem"] = sem + } + + return resp +} + +func pingHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +func perfHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + resp := buildPerfSnapshot() + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(resp) +} + +func perfStreamHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) + return + } + + ms := 5000 + if q := r.URL.Query().Get("ms"); q != "" { + if v, err := strconv.Atoi(q); err == nil { + if v < 1000 { + v = 1000 + } + if v > 30000 { + v = 30000 + } + ms = v + } + } + + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ctx := r.Context() + + send := func() error { + payload := buildPerfSnapshot() + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(payload); err != nil { + return err + } + + _, _ = io.WriteString(w, "event: perf\n") + _, _ = io.WriteString(w, "data: ") + _, _ = w.Write(buf.Bytes()) + _, _ = io.WriteString(w, "\n") + fl.Flush() + return nil + } + + _ = send() + + t := time.NewTicker(time.Duration(ms) * time.Millisecond) + hb := time.NewTicker(15 * time.Second) + defer t.Stop() + defer hb.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + _ = send() + case <-hb.C: + _, _ = io.WriteString(w, ": keep-alive\n\n") + fl.Flush() + } + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1c7f790..c67c514 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3435,7 +3435,7 @@ export default function App() {
-
+
@@ -3589,7 +3589,7 @@ export default function App() {
-
+
diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx index 2411a83..1dfe96d 100644 --- a/frontend/src/components/ui/Checkbox.tsx +++ b/frontend/src/components/ui/Checkbox.tsx @@ -34,7 +34,7 @@ export default function Checkbox({ }, [indeterminate, checked]) return ( -
+
: } disabled={bulkBusy} onClick={() => { if (allPageSelected) deselectPageRows(pageRows) else selectPageRows(pageRows) }} - className={[ - 'w-full inline-flex items-center justify-center gap-2 font-medium', - allPageSelected - ? 'text-amber-700 ring-amber-200 hover:bg-amber-50 dark:text-amber-300 dark:ring-amber-500/20 dark:hover:bg-amber-500/10' - : 'text-indigo-700 ring-indigo-200 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/10', - ].join(' ')} + className="w-full justify-center" > - {allPageSelected ? ( - - ) : ( - - )} - {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'} + {allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
@@ -2972,35 +2966,25 @@ export default function FinishedDownloads({
@@ -3008,23 +2992,23 @@ export default function FinishedDownloads({
diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index df9dffe..0f5b2ab 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -811,7 +811,7 @@ export default function FinishedDownloadsCardsView({ {!isSmall ? (
{ diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index c6f0ccb..fa8a04e 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -509,7 +509,7 @@ export default function FinishedDownloadsGalleryView({ >
{