updated
This commit is contained in:
parent
82f8b5269d
commit
19b473f8bd
417
backend/main.go
417
backend/main.go
@ -11,22 +11,17 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafov/m3u8"
|
"github.com/grafov/m3u8"
|
||||||
gocpu "github.com/shirou/gopsutil/v3/cpu"
|
|
||||||
godisk "github.com/shirou/gopsutil/v3/disk"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
|
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
|
||||||
@ -213,13 +208,6 @@ var (
|
|||||||
jobsMu = sync.Mutex{}
|
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() {
|
func startPreviewIdleKiller() {
|
||||||
t := time.NewTicker(5 * time.Second)
|
t := time.NewTicker(5 * time.Second)
|
||||||
go func() {
|
go func() {
|
||||||
@ -254,8 +242,8 @@ func startPreviewIdleKiller() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
initFFmpegSemaphores()
|
initFFmpegLimiters()
|
||||||
startAdaptiveSemController(context.Background())
|
startCPUUsageSampler(context.Background())
|
||||||
startPreviewIdleKiller()
|
startPreviewIdleKiller()
|
||||||
|
|
||||||
initSSE()
|
initSSE()
|
||||||
@ -328,228 +316,6 @@ func detectFFprobePath() string {
|
|||||||
return "ffprobe"
|
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 {
|
type durEntry struct {
|
||||||
size int64
|
size int64
|
||||||
mod time.Time
|
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})$`,
|
`^(.+)_([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) {
|
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
|
||||||
// returns: (shouldDelete, sizeBytes, thresholdBytes)
|
// returns: (shouldDelete, sizeBytes, thresholdBytes)
|
||||||
|
|
||||||
|
|||||||
343
backend/performance.go
Normal file
343
backend/performance.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3435,7 +3435,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<header className="z-30 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
<header className="z-[70] bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-3 sm:py-4 space-y-2 sm:space-y-3">
|
||||||
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
|
<div className="flex items-center sm:items-start justify-between gap-3 sm:gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -3589,7 +3589,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="sm:hidden sticky top-0 z-20 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
<div className="sm:hidden sticky top-0 z-[65] border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
|
||||||
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
|
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
|
||||||
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
|
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export default function Checkbox({
|
|||||||
}, [indeterminate, checked])
|
}, [indeterminate, checked])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
|
<div className={['relative z-0 group grid size-4 grid-cols-1', className].filter(Boolean).join(' ')}>
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
@ -2905,57 +2905,51 @@ export default function FinishedDownloads({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color={allPageSelected ? 'amber' : 'indigo'}
|
||||||
|
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allPageSelected) deselectPageRows(pageRows)
|
if (allPageSelected) deselectPageRows(pageRows)
|
||||||
else selectPageRows(pageRows)
|
else selectPageRows(pageRows)
|
||||||
}}
|
}}
|
||||||
className={[
|
className="w-full justify-center"
|
||||||
'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(' ')}
|
|
||||||
>
|
>
|
||||||
{allPageSelected ? (
|
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||||||
<XMarkIcon className="size-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CheckIcon className="size-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="secondary"
|
||||||
|
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
<XMarkIcon className="size-4 shrink-0" />
|
Auswahl aufheben
|
||||||
<span>Auswahl aufheben</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color="emerald"
|
||||||
|
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => void bulkKeepSelected()}
|
onClick={() => void bulkKeepSelected()}
|
||||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
<ArchiveBoxIcon className="size-4 shrink-0" />
|
Behalten
|
||||||
<span>Behalten</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color="red"
|
||||||
|
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => void bulkDeleteSelected()}
|
onClick={() => void bulkDeleteSelected()}
|
||||||
className="w-full inline-flex items-center justify-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10"
|
className="w-full justify-center"
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-4 shrink-0" />
|
Löschen
|
||||||
<span>Löschen</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2972,35 +2966,25 @@ export default function FinishedDownloads({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color={allPageSelected ? 'amber' : 'indigo'}
|
||||||
|
leadingIcon={allPageSelected ? <XMarkIcon className="size-4 shrink-0" /> : <CheckIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allPageSelected) deselectPageRows(pageRows)
|
if (allPageSelected) deselectPageRows(pageRows)
|
||||||
else selectPageRows(pageRows)
|
else selectPageRows(pageRows)
|
||||||
}}
|
}}
|
||||||
className={[
|
|
||||||
'inline-flex items-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(' ')}
|
|
||||||
>
|
>
|
||||||
{allPageSelected ? (
|
{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}
|
||||||
<XMarkIcon className="size-4 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<CheckIcon className="size-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span>{allPageSelected ? 'Seite abwählen' : 'Seite auswählen'}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="secondary"
|
||||||
|
leadingIcon={<XMarkIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
className="inline-flex items-center gap-2 font-medium text-gray-700 ring-gray-200 hover:bg-gray-50 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10"
|
|
||||||
>
|
>
|
||||||
<XMarkIcon className="size-4 shrink-0" />
|
Auswahl aufheben
|
||||||
<span>Auswahl aufheben</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="h-5 w-px bg-gray-200 dark:bg-white/10" />
|
<div className="h-5 w-px bg-gray-200 dark:bg-white/10" />
|
||||||
@ -3008,23 +2992,23 @@ export default function FinishedDownloads({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color="emerald"
|
||||||
|
leadingIcon={<ArchiveBoxIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => void bulkKeepSelected()}
|
onClick={() => void bulkKeepSelected()}
|
||||||
className="inline-flex items-center gap-2 font-medium text-emerald-700 ring-emerald-200 hover:bg-emerald-50 dark:text-emerald-300 dark:ring-emerald-500/20 dark:hover:bg-emerald-500/10"
|
|
||||||
>
|
>
|
||||||
<ArchiveBoxIcon className="size-4 shrink-0" />
|
Behalten
|
||||||
<span>Behalten</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
|
color="red"
|
||||||
|
leadingIcon={<TrashIcon className="size-4 shrink-0" />}
|
||||||
disabled={bulkBusy}
|
disabled={bulkBusy}
|
||||||
onClick={() => void bulkDeleteSelected()}
|
onClick={() => void bulkDeleteSelected()}
|
||||||
className="inline-flex items-center gap-2 font-medium text-red-700 ring-red-200 hover:bg-red-50 dark:text-red-300 dark:ring-red-500/20 dark:hover:bg-red-500/10"
|
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-4 shrink-0" />
|
Löschen
|
||||||
<span>Löschen</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -811,7 +811,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
{!isSmall ? (
|
{!isSmall ? (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute left-3 top-3 z-30 transition-opacity duration-150',
|
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@ -509,7 +509,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
'absolute left-3 top-3 z-30 transition-opacity duration-150',
|
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user