bugfixes and optimizations
This commit is contained in:
parent
bd34f95ec2
commit
ae957af7b2
@ -992,7 +992,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
|
||||
|
||||
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
||||
|
||||
ok := enrichQ.Enqueue(PostWorkTask{
|
||||
ok := enqueueEnrichPostwork(PostWorkTask{
|
||||
Key: "enrich:" + assetID,
|
||||
Added: time.Now(),
|
||||
SortBucket: sortBucket,
|
||||
|
||||
@ -252,6 +252,45 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (pq *PostWorkQueue) CancelAll() (queuedRemoved int, runningCancelled int) {
|
||||
pq.mu.Lock()
|
||||
|
||||
waiting := append([]string(nil), pq.waitingKeys...)
|
||||
runningCancels := make([]context.CancelFunc, 0, len(pq.cancelByKey))
|
||||
|
||||
for key, cancel := range pq.cancelByKey {
|
||||
if _, running := pq.runningKeys[key]; running && cancel != nil {
|
||||
runningCancels = append(runningCancels, cancel)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range waiting {
|
||||
delete(pq.inflight, key)
|
||||
pq.unlockFileLocked(key)
|
||||
|
||||
if pq.queued > 0 {
|
||||
pq.queued--
|
||||
}
|
||||
|
||||
queuedRemoved++
|
||||
}
|
||||
|
||||
pq.waitingKeys = nil
|
||||
pq.queue = nil
|
||||
|
||||
runningCancelled = len(runningCancels)
|
||||
|
||||
pq.mu.Unlock()
|
||||
|
||||
for _, cancel := range runningCancels {
|
||||
cancel()
|
||||
}
|
||||
|
||||
publishTaskState()
|
||||
|
||||
return queuedRemoved, runningCancelled
|
||||
}
|
||||
|
||||
func (pq *PostWorkQueue) workerLoop(id int) {
|
||||
for {
|
||||
pq.mu.Lock()
|
||||
@ -393,6 +432,19 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
||||
var postWorkQ = NewPostWorkQueue(1024, 1) // schneller kritischer Pfad
|
||||
var enrichQ = NewPostWorkQueue(128, 1) // langsame Hintergrundaufgaben
|
||||
|
||||
func enrichPostworkEnabled() bool {
|
||||
return getSettings().EnrichPostworkEnabled
|
||||
}
|
||||
|
||||
func enqueueEnrichPostwork(task PostWorkTask) bool {
|
||||
if !enrichPostworkEnabled() {
|
||||
appLogln("⏭️ enrich postwork deaktiviert, überspringe:", task.Key)
|
||||
return false
|
||||
}
|
||||
|
||||
return enrichQ.Enqueue(task)
|
||||
}
|
||||
|
||||
// --- Status Refresher (ehemals postwork_refresh.go) ---
|
||||
|
||||
type enrichStatusSnapshot struct {
|
||||
|
||||
@ -1274,9 +1274,6 @@ func extractLastFrameJPG(path string) ([]byte, error) {
|
||||
}
|
||||
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, appErrorf("ffmpeg last-frame jpg: empty output")
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
@ -1314,9 +1311,6 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) {
|
||||
return nil, ffmpegPreviewError("ffmpeg frame-at-time jpg", err, stderr.String())
|
||||
}
|
||||
b := out.Bytes()
|
||||
if len(b) == 0 {
|
||||
return nil, appErrorf("ffmpeg frame-at-time jpg: empty output")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
"generateAssetsTeaser": true,
|
||||
"generateAssetsSprites": false,
|
||||
"generateAssetsAnalyze": false,
|
||||
"enrichPostworkEnabled": false,
|
||||
"trainingRecognitionEnabled": true,
|
||||
"trainingDetectorEpochs": 60,
|
||||
"encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A=="
|
||||
|
||||
@ -44,6 +44,7 @@ type RecorderSettings struct {
|
||||
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
|
||||
|
||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||
@ -84,6 +85,7 @@ var (
|
||||
GenerateAssetsTeaser: true,
|
||||
GenerateAssetsSprites: false,
|
||||
GenerateAssetsAnalyze: false,
|
||||
EnrichPostworkEnabled: true,
|
||||
|
||||
TrainingRecognitionEnabled: true,
|
||||
TrainingDetectorEpochs: 60,
|
||||
@ -256,6 +258,7 @@ type RecorderSettingsPublic struct {
|
||||
GenerateAssetsTeaser bool `json:"generateAssetsTeaser"`
|
||||
GenerateAssetsSprites bool `json:"generateAssetsSprites"`
|
||||
GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"`
|
||||
EnrichPostworkEnabled bool `json:"enrichPostworkEnabled"`
|
||||
|
||||
TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"`
|
||||
TrainingDetectorEpochs int `json:"trainingDetectorEpochs"`
|
||||
@ -293,6 +296,7 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
|
||||
GenerateAssetsTeaser: s.GenerateAssetsTeaser,
|
||||
GenerateAssetsSprites: s.GenerateAssetsSprites,
|
||||
GenerateAssetsAnalyze: s.GenerateAssetsAnalyze,
|
||||
EnrichPostworkEnabled: s.EnrichPostworkEnabled,
|
||||
|
||||
TrainingRecognitionEnabled: s.TrainingRecognitionEnabled,
|
||||
TrainingDetectorEpochs: s.TrainingDetectorEpochs,
|
||||
@ -455,11 +459,14 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
next.EnableNotifications = in.EnableNotifications
|
||||
|
||||
oldEnrichPostworkEnabled := current.EnrichPostworkEnabled
|
||||
|
||||
next.GenerateAssetsMeta = in.GenerateAssetsMeta
|
||||
next.GenerateAssetsThumb = in.GenerateAssetsThumb
|
||||
next.GenerateAssetsTeaser = in.GenerateAssetsTeaser
|
||||
next.GenerateAssetsSprites = in.GenerateAssetsSprites
|
||||
next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze
|
||||
next.EnrichPostworkEnabled = in.EnrichPostworkEnabled
|
||||
|
||||
next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled
|
||||
next.TrainingDetectorEpochs = in.TrainingDetectorEpochs
|
||||
@ -492,6 +499,18 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
saveSettingsToDisk()
|
||||
|
||||
if oldEnrichPostworkEnabled && !next.EnrichPostworkEnabled {
|
||||
cancelledQueued, cancelledRunning := enrichQ.CancelAll()
|
||||
|
||||
if cancelledQueued > 0 || cancelledRunning > 0 {
|
||||
appLogln(
|
||||
"🛑 enrich postwork deaktiviert:",
|
||||
"queuedRemoved=", cancelledQueued,
|
||||
"runningCancelled=", cancelledRunning,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if newStore != nil {
|
||||
setModelStore(newStore)
|
||||
setChaturbateOnlineModelStore(newStore)
|
||||
|
||||
@ -11,24 +11,185 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const startupLeftoverMinAge = 60 * time.Second
|
||||
const (
|
||||
startupLeftoverMinAge = 60 * time.Second
|
||||
|
||||
// .muxing.ts sind temporäre Zwischenstände.
|
||||
// Nur alte Dateien löschen, damit ein sehr frischer/noch schreibender Prozess
|
||||
// nicht versehentlich getroffen wird.
|
||||
startupMuxingTempMinAge = 10 * time.Minute
|
||||
|
||||
postworkLeftoverInitialDelay = 8 * time.Second
|
||||
postworkLeftoverScanInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
func startPostworkLeftoverScanOnStartup() {
|
||||
go func() {
|
||||
// Kurz warten, bis Settings, Queues und UI/SSE bereit sind.
|
||||
time.Sleep(8 * time.Second)
|
||||
time.Sleep(postworkLeftoverInitialDelay)
|
||||
|
||||
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
|
||||
if queued > 0 || skipped > 0 {
|
||||
appLogln(
|
||||
"🧩 [startup-postwork] leftover scan fertig:",
|
||||
"queued=", queued,
|
||||
"skipped=", skipped,
|
||||
)
|
||||
runPostworkLeftoverScan := func(reason string) {
|
||||
removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir()
|
||||
|
||||
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
|
||||
if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 {
|
||||
appLogln(
|
||||
"🧩 [startup-postwork] leftover scan fertig:",
|
||||
"reason=", reason,
|
||||
"queued=", queued,
|
||||
"skipped=", skipped,
|
||||
"removedMuxing=", removedMuxing,
|
||||
"skippedMuxing=", skippedMuxing,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
runPostworkLeftoverScan("startup")
|
||||
|
||||
ticker := time.NewTicker(postworkLeftoverScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
runPostworkLeftoverScan("interval")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||
s := getSettings()
|
||||
|
||||
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
|
||||
if err != nil || strings.TrimSpace(recordAbs) == "" {
|
||||
recordAbs = strings.TrimSpace(s.RecordDir)
|
||||
}
|
||||
|
||||
recordAbs = strings.TrimSpace(recordAbs)
|
||||
if recordAbs == "" {
|
||||
appLogln("⚠️ [startup-postwork] recordDir ist leer")
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(recordAbs)
|
||||
if err != nil {
|
||||
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, e := range entries {
|
||||
if e == nil || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(e.Name())
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isMuxingTempTSName(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(recordAbs, name)
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if fi.Size() <= 0 {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
skipped++
|
||||
appLogln("⚠️ [startup-postwork] .muxing.ts leer, aber löschen fehlgeschlagen:", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
removed++
|
||||
appLogln("🗑️ [startup-postwork] leere .muxing.ts gelöscht:", name)
|
||||
continue
|
||||
}
|
||||
|
||||
age := now.Sub(fi.ModTime())
|
||||
if age < startupMuxingTempMinAge {
|
||||
skipped++
|
||||
appLogln(
|
||||
"⏭️ [startup-postwork] .muxing.ts zu frisch:",
|
||||
name,
|
||||
"age=", age.Round(time.Second),
|
||||
"min=", startupMuxingTempMinAge,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if !singleFileStable(path) {
|
||||
skipped++
|
||||
appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Falls für diesen temporären Namen ein Audio-Sidecar existiert, direkt mit entfernen.
|
||||
removedAudioSidecars := removePostworkAudioSidecarsForOutput(path)
|
||||
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
skipped++
|
||||
appLogln("⚠️ [startup-postwork] .muxing.ts löschen fehlgeschlagen:", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
removed++
|
||||
appLogln(
|
||||
"🗑️ [startup-postwork] alte .muxing.ts gelöscht:",
|
||||
name,
|
||||
"audioSidecars=", len(removedAudioSidecars),
|
||||
)
|
||||
}
|
||||
|
||||
return removed, skipped
|
||||
}
|
||||
|
||||
func isMuxingTempTSName(name string) bool {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
lowerName := strings.ToLower(name)
|
||||
|
||||
return strings.HasSuffix(lowerName, ".muxing.ts")
|
||||
}
|
||||
|
||||
func singleFileStable(path string) bool {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
sizeOf := func(p string) (int64, bool) {
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return fi.Size(), true
|
||||
}
|
||||
|
||||
size1, ok1 := sizeOf(path)
|
||||
if !ok1 {
|
||||
return false
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
size2, ok2 := sizeOf(path)
|
||||
if !ok2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return size1 == size2
|
||||
}
|
||||
|
||||
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
s := getSettings()
|
||||
|
||||
|
||||
@ -33,7 +33,12 @@ type cleanupResp struct {
|
||||
DeletedBytesHuman string `json:"deletedBytesHuman"`
|
||||
DeletedRecordOrphans int `json:"deletedRecordOrphans"`
|
||||
DeletedRecordOrphanBytes int64 `json:"deletedRecordOrphanBytes"`
|
||||
ErrorCount int `json:"errorCount"`
|
||||
|
||||
DeletedMuxingTemps int `json:"deletedMuxingTemps"`
|
||||
DeletedAudioOrphans int `json:"deletedAudioOrphans"`
|
||||
DeletedTSOrphans int `json:"deletedTSOrphans"`
|
||||
|
||||
ErrorCount int `json:"errorCount"`
|
||||
|
||||
// Generated-GC
|
||||
GeneratedOrphansChecked int `json:"generatedOrphansChecked"`
|
||||
@ -246,7 +251,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
finishCleanupJob(
|
||||
jobID,
|
||||
cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes),
|
||||
cleanupProgressText(resp),
|
||||
nil,
|
||||
)
|
||||
}(jobID, ctx, doneAbs, recordAbs, mb)
|
||||
@ -278,23 +283,42 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupProgressText(deleted int, broken int, total int, freedBytes int64) string {
|
||||
if broken > 0 {
|
||||
return fmt.Sprintf(
|
||||
"gelöscht: %d/%d · fehlerhaft: %d · freigegeben: %s",
|
||||
deleted,
|
||||
total,
|
||||
broken,
|
||||
formatBytesSI(freedBytes),
|
||||
)
|
||||
func cleanupProgressText(resp cleanupResp) string {
|
||||
parts := []string{
|
||||
fmt.Sprintf("gelöscht: %d/%d", resp.DeletedFiles, resp.ScannedFiles),
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"gelöscht: %d/%d · freigegeben: %s",
|
||||
deleted,
|
||||
total,
|
||||
formatBytesSI(freedBytes),
|
||||
)
|
||||
if resp.DeletedBrokenFiles > 0 {
|
||||
parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles))
|
||||
}
|
||||
|
||||
if resp.DeletedRecordOrphans > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans))
|
||||
}
|
||||
|
||||
if resp.DeletedTSOrphans > 0 {
|
||||
parts = append(parts, fmt.Sprintf("TS ohne Audio: %d", resp.DeletedTSOrphans))
|
||||
}
|
||||
|
||||
if resp.DeletedAudioOrphans > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Audio ohne TS: %d", resp.DeletedAudioOrphans))
|
||||
}
|
||||
|
||||
if resp.DeletedMuxingTemps > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Muxing-Temp: %d", resp.DeletedMuxingTemps))
|
||||
}
|
||||
|
||||
if resp.GeneratedOrphansRemoved > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Generated: %d", resp.GeneratedOrphansRemoved))
|
||||
}
|
||||
|
||||
if resp.ErrorCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Fehler: %d", resp.ErrorCount))
|
||||
}
|
||||
|
||||
parts = append(parts, fmt.Sprintf("freigegeben: %s", formatBytesSI(resp.DeletedBytes)))
|
||||
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func cleanupVideoLooksBroken(ctx context.Context, path string) (bool, string) {
|
||||
@ -444,10 +468,14 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
||||
}
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
||||
if err != nil || fi == nil || fi.IsDir() {
|
||||
return
|
||||
}
|
||||
|
||||
size := fi.Size()
|
||||
name := filepath.Base(path)
|
||||
lowName := strings.ToLower(name)
|
||||
|
||||
if time.Since(fi.ModTime()) < cleanupRecordOrphanMinAge {
|
||||
resp.SkippedFiles++
|
||||
return
|
||||
@ -458,38 +486,49 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
||||
return
|
||||
}
|
||||
|
||||
if !cleanupFileStable(path) {
|
||||
// 0-KB-Dateien sind stabil genug zum Löschen.
|
||||
// Für >0 Bytes weiter die Stabilitätsprüfung behalten.
|
||||
if size > 0 && !cleanupFileStable(path) {
|
||||
resp.SkippedFiles++
|
||||
return
|
||||
}
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.CurrentFile = filepath.Base(path)
|
||||
st.Text = "Räume verwaiste Record-Dateien auf…"
|
||||
st.CurrentFile = name
|
||||
st.Text = "Räume Record-Reste auf…"
|
||||
})
|
||||
|
||||
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
|
||||
resp.ErrorCount++
|
||||
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", filepath.Base(path), reason, err)
|
||||
appLogln("⚠️ cleanup record orphan konnte nicht gelöscht werden:", name, reason, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp.DeletedFiles++
|
||||
resp.DeletedRecordOrphans++
|
||||
resp.DeletedBytes += fi.Size()
|
||||
resp.DeletedRecordOrphanBytes += fi.Size()
|
||||
resp.DeletedBytes += size
|
||||
resp.DeletedRecordOrphanBytes += size
|
||||
|
||||
if strings.HasSuffix(lowName, ".muxing.ts") {
|
||||
resp.DeletedMuxingTemps++
|
||||
} else if strings.HasPrefix(name, ".") && strings.HasSuffix(lowName, ".audio.m4s") {
|
||||
resp.DeletedAudioOrphans++
|
||||
} else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||
resp.DeletedTSOrphans++
|
||||
}
|
||||
|
||||
purgeDurationCacheForPath(path)
|
||||
|
||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
base = strings.TrimPrefix(base, ".")
|
||||
base = strings.TrimSuffix(base, ".audio")
|
||||
base = strings.TrimSuffix(base, ".muxing")
|
||||
|
||||
if strings.TrimSpace(base) != "" {
|
||||
removeGeneratedForID(stripHotPrefix(base))
|
||||
}
|
||||
|
||||
appLogln("🧹 cleanup deleted record orphan:", filepath.Base(path), "reason:", reason)
|
||||
appLogln("🧹 cleanup deleted record orphan:", name, "reason:", reason, "size=", size)
|
||||
}
|
||||
|
||||
func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs string, resp *cleanupResp) error {
|
||||
@ -522,6 +561,14 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
|
||||
full := filepath.Join(recordAbs, name)
|
||||
low := strings.ToLower(name)
|
||||
|
||||
// Fall 0:
|
||||
// alte .muxing.ts sind temporäre Remux-/Muxing-Reste.
|
||||
if strings.HasSuffix(low, ".muxing.ts") {
|
||||
resp.ScannedFiles++
|
||||
cleanupDeleteRecordOrphanFile(ctx, jobID, full, resp, "stale muxing temp")
|
||||
continue
|
||||
}
|
||||
|
||||
// Fall 1:
|
||||
// foo.ts existiert, aber .foo.audio.m4s fehlt.
|
||||
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||
@ -658,7 +705,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
st.Done = 0
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = ""
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Text = cleanupProgressText(*resp)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
@ -716,7 +763,7 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh
|
||||
st.Done = i + 1
|
||||
st.Total = resp.ScannedFiles
|
||||
st.CurrentFile = c.name
|
||||
st.Text = cleanupProgressText(resp.DeletedFiles, resp.DeletedBrokenFiles, resp.ScannedFiles, resp.DeletedBytes)
|
||||
st.Text = cleanupProgressText(*resp)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
|
||||
@ -69,11 +69,23 @@ const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = {
|
||||
useMyFreeCamsWatcher: false,
|
||||
autoDeleteSmallDownloads: false,
|
||||
autoDeleteSmallDownloadsBelowMB: 200,
|
||||
autoDeleteSmallDownloadsKeepFavorites: true,
|
||||
blurPreviews: false,
|
||||
teaserPlayback: 'hover',
|
||||
teaserAudio: false,
|
||||
lowDiskPauseBelowGB: 5,
|
||||
enableNotifications: true,
|
||||
|
||||
generateAssetsMeta: true,
|
||||
generateAssetsThumb: true,
|
||||
generateAssetsTeaser: true,
|
||||
generateAssetsSprites: true,
|
||||
generateAssetsAnalyze: true,
|
||||
|
||||
enrichPostworkEnabled: true,
|
||||
|
||||
trainingRecognitionEnabled: true,
|
||||
trainingDetectorEpochs: 60,
|
||||
}
|
||||
|
||||
type StoredModel = {
|
||||
@ -3812,14 +3824,30 @@ export default function App() {
|
||||
return <LoginPage onLoggedIn={checkAuth} />
|
||||
}
|
||||
|
||||
const formatHeaderStatValue = (value: unknown) => {
|
||||
const n = Number(value)
|
||||
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
if (n >= 10000) return `${Math.round(n / 1000)}k`
|
||||
if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
|
||||
|
||||
return String(Math.max(0, Math.floor(n)))
|
||||
}
|
||||
|
||||
const headerShellClass =
|
||||
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06]'
|
||||
'rounded-2xl border border-gray-200/70 bg-white/65 p-3 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] max-lg:[@media_(orientation:landscape)]:rounded-xl max-lg:[@media_(orientation:landscape)]:p-2'
|
||||
|
||||
const headerStatClass =
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||
'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||
|
||||
const mobileHeaderStatClass =
|
||||
'flex h-8 min-w-0 items-center justify-center gap-1 rounded-lg border border-gray-200/70 bg-white/75 px-1.5 text-[10px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
|
||||
|
||||
const headerStatValueClass =
|
||||
'inline-block min-w-[3ch] text-right tabular-nums text-gray-950 dark:text-white'
|
||||
|
||||
const headerActionClass =
|
||||
'h-9 shrink-0 rounded-xl px-3 text-sm'
|
||||
'h-9 shrink-0 rounded-xl px-3 text-sm max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:rounded-lg max-lg:[@media_(orientation:landscape)]:px-2.5 max-lg:[@media_(orientation:landscape)]:text-xs'
|
||||
|
||||
const headerStatItems = [
|
||||
{
|
||||
@ -3858,60 +3886,79 @@ export default function App() {
|
||||
|
||||
<div className="relative lg:flex lg:h-full lg:min-h-0 lg:flex-col">
|
||||
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
|
||||
<div className="mx-auto max-w-7xl p-3">
|
||||
<div className="mx-auto max-w-7xl p-3 max-lg:[@media_(orientation:landscape)]:p-1.5">
|
||||
<div className={headerShellClass}>
|
||||
<div className="grid gap-2.5">
|
||||
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid gap-2.5 max-lg:[@media_(orientation:landscape)]:gap-1.5">
|
||||
<div className="flex flex-col gap-2.5 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-start max-lg:[@media_(orientation:landscape)]:justify-between max-lg:[@media_(orientation:landscape)]:gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20">
|
||||
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20 max-lg:[@media_(orientation:landscape)]:h-8 max-lg:[@media_(orientation:landscape)]:w-8 max-lg:[@media_(orientation:landscape)]:rounded-lg">
|
||||
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg">
|
||||
Recorder
|
||||
</h1>
|
||||
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg max-lg:[@media_(orientation:landscape)]:text-base">
|
||||
Recorder
|
||||
</h1>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{headerStatItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
<div className="hidden min-w-0 flex-wrap items-center gap-1.5 lg:flex">
|
||||
{headerStatItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className={headerStatClass}
|
||||
>
|
||||
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className={headerStatClass}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
||||
|
||||
<span className="tabular-nums text-gray-950 dark:text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
<span className={headerStatValueClass}>
|
||||
{formatHeaderStatValue(item.value)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px]">
|
||||
<LastUpdatedText
|
||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||
fetchedAt={cbOnlineFetchedAt}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1.5 lg:hidden max-lg:[@media_(orientation:landscape)]:hidden">
|
||||
{headerStatItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
key={item.label}
|
||||
className={mobileHeaderStatClass}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 opacity-80" aria-hidden="true" />
|
||||
|
||||
<span className={headerStatValueClass}>
|
||||
{formatHeaderStatValue(item.value)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px] max-lg:[@media_(orientation:landscape)]:hidden">
|
||||
<LastUpdatedText
|
||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||
fetchedAt={cbOnlineFetchedAt}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
|
||||
<div className="flex flex-col gap-2 max-lg:[@media_(orientation:landscape)]:flex-row max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:justify-end sm:flex-row sm:items-center lg:justify-end">
|
||||
{showPerfMon ? (
|
||||
<PerformanceMonitor
|
||||
mode="inline"
|
||||
compact
|
||||
className="w-full sm:w-[300px] lg:w-[360px]"
|
||||
className="w-full max-lg:[@media_(orientation:landscape)]:w-[300px] sm:w-[300px] lg:w-[360px]"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:items-center sm:gap-2">
|
||||
<div className="grid grid-cols-2 gap-1.5 max-lg:[@media_(orientation:landscape)]:flex max-lg:[@media_(orientation:landscape)]:items-center max-lg:[@media_(orientation:landscape)]:gap-2 sm:flex sm:items-center sm:gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setCookieModalOpen(true)}
|
||||
@ -3938,8 +3985,8 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<div className="relative">
|
||||
<div className="grid min-w-0 gap-2 max-lg:[@media_(orientation:landscape)]:grid-cols-[minmax(0,1fr)_7.5rem] max-lg:[@media_(orientation:landscape)]:items-center lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="relative min-w-0">
|
||||
<label className="sr-only">Source URL</label>
|
||||
<TextInput
|
||||
ref={sourceUrlInputRef}
|
||||
@ -4002,6 +4049,7 @@ export default function App() {
|
||||
onChange={setSelectedTab}
|
||||
ariaLabel="Tabs"
|
||||
variant="barUnderline"
|
||||
desktopBreakpoint="lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -640,7 +640,7 @@ export default function Downloads({
|
||||
enableConcurrentDownloadsLimit = false,
|
||||
maxConcurrentDownloads = 20,
|
||||
}: Props) {
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)', true)
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)', true)
|
||||
|
||||
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||
|
||||
|
||||
@ -4725,7 +4725,7 @@ export default function FinishedDownloads({
|
||||
<>
|
||||
<div className="grid w-full gap-4 px-4 sm:px-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:items-start lg:pl-8 lg:pr-[max(2rem,calc((100vw-80rem)/2+2rem))] xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
{/* Toolbar links neben dem Content, nicht per Transform aus dem Container geschoben */}
|
||||
<aside className="hidden min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:block lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
||||
<aside className="relative z-40 min-w-0 lg:sticky lg:top-0 lg:col-start-1 lg:row-start-1 lg:z-[60] lg:w-[18rem] lg:justify-self-end lg:self-start lg:max-h-[calc(100dvh-14rem)] lg:overflow-y-auto lg:overflow-x-hidden xl:w-[19rem]">
|
||||
<div
|
||||
className="
|
||||
rounded-xl border border-gray-200/70 bg-white/95 shadow-sm
|
||||
@ -4735,7 +4735,7 @@ export default function FinishedDownloads({
|
||||
>
|
||||
{/* Desktop */}
|
||||
{/* Sidebar */}
|
||||
<div className="hidden sm:block px-3 py-2.5">
|
||||
<div className="hidden lg:block px-3 py-2.5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
@ -4786,7 +4786,7 @@ export default function FinishedDownloads({
|
||||
: '-translate-y-1 pointer-events-none',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="sm:hidden">
|
||||
<div className="lg:hidden">
|
||||
{bulkBusy ? (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-gray-600 dark:text-gray-300">
|
||||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||||
@ -4851,7 +4851,7 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:grid sm:grid-cols-2 sm:gap-2">
|
||||
<div className="hidden lg:grid lg:grid-cols-2 lg:gap-2">
|
||||
{bulkBusy ? (
|
||||
<div className="mr-2 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<LoadingSpinner size="sm" srLabel="Bulk-Aktion läuft…" />
|
||||
@ -5079,7 +5079,7 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="sm:hidden">
|
||||
<div className="lg:hidden">
|
||||
<div className="flex items-center gap-2 px-3 pt-2.5 pb-1.5">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
@ -5185,7 +5185,7 @@ export default function FinishedDownloads({
|
||||
</div>
|
||||
|
||||
{(allKnownTags.length > 0 || tagFilter.length > 0) ? (
|
||||
<div className="hidden sm:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
|
||||
<div className="hidden lg:block border-t border-gray-200/60 dark:border-white/10 px-3 py-2.5">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
Tag-Filter
|
||||
@ -5201,7 +5201,7 @@ export default function FinishedDownloads({
|
||||
<div
|
||||
id="finished-mobile-options"
|
||||
className={[
|
||||
'sm:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
||||
'lg:hidden overflow-hidden transition-[max-height,opacity] duration-200 ease-in-out',
|
||||
mobileOptionsOpen ? 'max-h-[720px] opacity-100' : 'max-h-0 opacity-0',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@ -43,9 +43,7 @@ import {
|
||||
type FinishedSelectionStore,
|
||||
} from './finishedSelectionStore'
|
||||
import SpritePreloader from './SpritePreloader'
|
||||
import FinishedDownloadsDesktopCard, {
|
||||
SelectionCheckboxOverlay,
|
||||
} from './FinishedDownloadsDesktopCard'
|
||||
import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard'
|
||||
import PreviewRatingOverlay from './PreviewRatingOverlay'
|
||||
import PreviewMetaOverlay from './PreviewMetaOverlay'
|
||||
|
||||
@ -641,15 +639,6 @@ function FinishedDownloadsCardsView({
|
||||
blurred={opts?.blur}
|
||||
animateUnblurOnMount={opts?.animateUnblurOnMount}
|
||||
>
|
||||
{!isSmall ? (
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Card shell keeps backgrounds consistent */}
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
{/* Preview */}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, type ReactNode } from 'react'
|
||||
import { memo, useCallback, useRef, type ReactNode } from 'react'
|
||||
import Card from './Card'
|
||||
import Checkbox from './Checkbox'
|
||||
import CountryFlag, { resolveFinishedCountry } from './CountryFlag'
|
||||
@ -116,37 +116,6 @@ function inlineStateForKey(
|
||||
}
|
||||
}
|
||||
|
||||
export function SelectionCheckboxOverlay({
|
||||
selectionStore,
|
||||
rowKey,
|
||||
label,
|
||||
onToggle,
|
||||
}: {
|
||||
selectionStore: FinishedSelectionStore
|
||||
rowKey: string
|
||||
label: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const checked = useSelectionChecked(selectionStore, rowKey)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'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',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={label}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FinishedDownloadsDesktopCard = memo(
|
||||
function FinishedDownloadsDesktopCard({
|
||||
job: j,
|
||||
@ -195,6 +164,9 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
}: FinishedDownloadsDesktopCardProps) {
|
||||
const k = keyFor(j)
|
||||
const busy = isDeleting || isKeeping || isRemoving
|
||||
const checked = useSelectionChecked(selectionStore, k)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const longPressTriggeredRef = useRef(false)
|
||||
const inlineActive = inlinePlay?.key === k
|
||||
const inlineNonce = inlineActive ? inlinePlay?.nonce ?? 0 : 0
|
||||
|
||||
@ -213,6 +185,25 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
return false
|
||||
}, [selectionStore, onToggleSelected, j])
|
||||
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startLongPressSelect = useCallback(() => {
|
||||
clearLongPressTimer()
|
||||
|
||||
if (selectionStore.hasAnySelection()) return
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
longPressTimerRef.current = null
|
||||
longPressTriggeredRef.current = true
|
||||
onToggleSelected(j)
|
||||
}, 450)
|
||||
}, [clearLongPressTimer, selectionStore, onToggleSelected, j])
|
||||
|
||||
const previewMuted = forcePreviewMuted ? true : !allowSound
|
||||
|
||||
const model = modelNameFromOutput(j.output)
|
||||
@ -263,6 +254,7 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
'transition-all duration-200',
|
||||
'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
||||
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||
busy && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
@ -272,11 +264,19 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (handleSelectOrOpen()) return
|
||||
openPlayer(j)
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => onHoverPreviewKeyChange?.(null)}
|
||||
onMouseLeave={() => {
|
||||
clearLongPressTimer()
|
||||
onHoverPreviewKeyChange?.(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
@ -285,14 +285,34 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
onOpenPlayer(j)
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
startLongPressSelect()
|
||||
}}
|
||||
onPointerUp={clearLongPressTimer}
|
||||
onPointerCancel={clearLongPressTimer}
|
||||
onPointerLeave={clearLongPressTimer}
|
||||
onContextMenu={(e) => {
|
||||
if (longPressTriggeredRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectionCheckboxOverlay
|
||||
selectionStore={selectionStore}
|
||||
rowKey={k}
|
||||
label={`Auswählen: ${baseName(j.output || '') || 'Download'}`}
|
||||
onToggle={() => onToggleSelected(j)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
ariaLabel={`${checked ? 'Abwählen' : 'Auswählen'}: ${baseName(j.output || '') || 'Download'}`}
|
||||
onChange={() => onToggleSelected(j)}
|
||||
/>
|
||||
</div>
|
||||
<Card noBodyPadding className="overflow-hidden bg-transparent">
|
||||
<div
|
||||
id={inlineDomId}
|
||||
@ -348,7 +368,18 @@ const FinishedDownloadsDesktopCard = memo(
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative min-h-[112px] overflow-visible border-t border-black/5 bg-white px-4 py-3 dark:border-white/10 dark:bg-gray-900"
|
||||
className={[
|
||||
'relative min-h-[112px] overflow-visible border-t px-4 py-3 transition-colors',
|
||||
checked
|
||||
? [
|
||||
'border-indigo-200 bg-indigo-50/90',
|
||||
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
|
||||
].join(' ')
|
||||
: [
|
||||
'border-black/5 bg-white',
|
||||
'dark:border-white/10 dark:bg-gray-900',
|
||||
].join(' '),
|
||||
].join(' ')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
@ -13,12 +13,12 @@ import RecordJobActions from './RecordJobActions'
|
||||
import TagOverflowRow from './TagOverflowRow'
|
||||
import { isHotName, stripHotPrefix } from './hotName'
|
||||
import PreviewScrubber from './PreviewScrubber'
|
||||
import Checkbox from './Checkbox'
|
||||
import type { FinishedDownloadsTeaserState } from './teaserPlayback'
|
||||
import {
|
||||
shouldAnimateTeaser,
|
||||
shouldEnableTeaserAudio,
|
||||
} from './teaserPlayback'
|
||||
import Checkbox from './Checkbox'
|
||||
import { SpeakerWaveIcon } from '@heroicons/react/24/solid'
|
||||
import {
|
||||
buildSpriteFrameStyle,
|
||||
@ -158,6 +158,8 @@ function FinishedDownloadsGalleryCardInner({
|
||||
}: Props) {
|
||||
const k = rowKey
|
||||
const checked = selectedKeys.has(rowKey)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const longPressTriggeredRef = useRef(false)
|
||||
|
||||
const [scrubIndexByKey, setScrubIndexByKey] = useState<Record<string, number | undefined>>({})
|
||||
const [hoveredThumbKey, setHoveredThumbKey] = useState<string | null>(null)
|
||||
@ -180,6 +182,11 @@ function FinishedDownloadsGalleryCardInner({
|
||||
e?.preventDefault()
|
||||
e?.stopPropagation()
|
||||
|
||||
if (longPressTriggeredRef.current) {
|
||||
longPressTriggeredRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (selectionModeActive) {
|
||||
onToggleSelected(j)
|
||||
return
|
||||
@ -190,6 +197,25 @@ function FinishedDownloadsGalleryCardInner({
|
||||
[selectionModeActive, onToggleSelected, onOpenPlayer, j]
|
||||
)
|
||||
|
||||
const clearLongPressTimer = useCallback(() => {
|
||||
if (longPressTimerRef.current !== null) {
|
||||
window.clearTimeout(longPressTimerRef.current)
|
||||
longPressTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startLongPressSelect = useCallback(() => {
|
||||
clearLongPressTimer()
|
||||
|
||||
if (selectionModeActive) return
|
||||
|
||||
longPressTimerRef.current = window.setTimeout(() => {
|
||||
longPressTimerRef.current = null
|
||||
longPressTriggeredRef.current = true
|
||||
onToggleSelected(j)
|
||||
}, 450)
|
||||
}, [clearLongPressTimer, selectionModeActive, onToggleSelected, j])
|
||||
|
||||
const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => {
|
||||
setScrubIndexByKey((prev) => {
|
||||
if (index === undefined) {
|
||||
@ -336,8 +362,21 @@ function FinishedDownloadsGalleryCardInner({
|
||||
onClick={(e) => {
|
||||
handlePrimaryClick(e)
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType === 'mouse') return
|
||||
startLongPressSelect()
|
||||
}}
|
||||
onPointerUp={clearLongPressTimer}
|
||||
onPointerCancel={clearLongPressTimer}
|
||||
onPointerLeave={clearLongPressTimer}
|
||||
onContextMenu={(e) => {
|
||||
if (longPressTriggeredRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onHoverPreviewKeyChange?.(k)}
|
||||
onMouseLeave={() => {
|
||||
clearLongPressTimer()
|
||||
onHoverPreviewKeyChange?.(null)
|
||||
setHoveredThumbKey((prev) => (prev === k ? null : prev))
|
||||
clearScrubIndex(k)
|
||||
@ -354,6 +393,7 @@ function FinishedDownloadsGalleryCardInner({
|
||||
'transition-[transform,opacity,box-shadow,background-color] duration-180 ease-out',
|
||||
'will-change-[transform,opacity]',
|
||||
!busy && 'hover:-translate-y-0.5 hover:shadow-md dark:hover:shadow-none',
|
||||
checked && 'ring-2 ring-indigo-500 bg-indigo-50/60 dark:bg-indigo-500/10 dark:ring-indigo-400',
|
||||
busy && !isRemoving && 'pointer-events-none opacity-70',
|
||||
isDeleting && 'ring-1 ring-red-300 bg-red-50/60 dark:bg-red-500/10 dark:ring-red-500/30',
|
||||
isKeeping && 'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30',
|
||||
@ -362,10 +402,11 @@ function FinishedDownloadsGalleryCardInner({
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'absolute left-3 top-3 z-20 transition-opacity duration-150',
|
||||
'absolute left-3 top-3 z-40 hidden transition-opacity duration-150 lg:block',
|
||||
checked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
@ -374,7 +415,6 @@ function FinishedDownloadsGalleryCardInner({
|
||||
onChange={() => onToggleSelected(j)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group/thumb relative aspect-video rounded-t-lg bg-black/5 dark:bg-white/5"
|
||||
ref={registerTeaserHost(k)}
|
||||
@ -513,7 +553,16 @@ function FinishedDownloadsGalleryCardInner({
|
||||
|
||||
<div
|
||||
className={[
|
||||
'relative flex flex-col rounded-b-lg border-t border-black/5 bg-white dark:border-white/10 dark:bg-gray-900',
|
||||
'relative flex flex-col rounded-b-lg border-t transition-colors',
|
||||
checked
|
||||
? [
|
||||
'border-indigo-200 bg-indigo-50/90',
|
||||
'dark:border-indigo-400/30 dark:bg-indigo-500/15',
|
||||
].join(' ')
|
||||
: [
|
||||
'border-black/5 bg-white',
|
||||
'dark:border-white/10 dark:bg-gray-900',
|
||||
].join(' '),
|
||||
footerMinHeightClass,
|
||||
contentPaddingClass,
|
||||
].join(' ')}
|
||||
|
||||
@ -280,18 +280,18 @@ export default function PerformanceMonitor({
|
||||
return (
|
||||
<div className={`${wrapperClass} ${className ?? ''}`}>
|
||||
<div className={panelClass}>
|
||||
<div className="grid grid-cols-2 gap-1 md:grid-cols-4 sm:h-full sm:w-full sm:items-center">
|
||||
<div className="grid w-full grid-cols-4 gap-1">
|
||||
{metrics.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
title={item.title}
|
||||
className="min-w-0 rounded-md px-1.5 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
|
||||
className="min-w-0 rounded-md px-1 py-0.5 sm:rounded-lg sm:px-2 sm:py-1"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1.5 whitespace-nowrap">
|
||||
<span className="min-w-0 truncate text-[9px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
|
||||
<div className="flex items-center justify-between gap-1 whitespace-nowrap">
|
||||
<span className="min-w-0 truncate text-[8px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400 sm:text-[10px]">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-[10px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
|
||||
<span className="shrink-0 whitespace-nowrap text-[9px] font-semibold leading-none tabular-nums text-gray-900 dark:text-gray-100 sm:text-[11px]">
|
||||
{item.value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -38,6 +38,7 @@ type RecorderSettings = {
|
||||
generateAssetsTeaser?: boolean
|
||||
generateAssetsSprites?: boolean
|
||||
generateAssetsAnalyze?: boolean
|
||||
enrichPostworkEnabled?: boolean
|
||||
|
||||
trainingRecognitionEnabled?: boolean
|
||||
trainingDetectorEpochs?: number
|
||||
@ -203,6 +204,7 @@ const DEFAULTS: RecorderSettings = {
|
||||
generateAssetsTeaser: true,
|
||||
generateAssetsSprites: true,
|
||||
generateAssetsAnalyze: true,
|
||||
enrichPostworkEnabled: true,
|
||||
|
||||
trainingRecognitionEnabled: true,
|
||||
trainingDetectorEpochs: 60,
|
||||
@ -592,6 +594,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser,
|
||||
generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites,
|
||||
generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze,
|
||||
enrichPostworkEnabled: (data as any).enrichPostworkEnabled ?? DEFAULTS.enrichPostworkEnabled,
|
||||
|
||||
trainingRecognitionEnabled:
|
||||
(data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled,
|
||||
@ -989,6 +992,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
const generateAssetsTeaser = !!value.generateAssetsTeaser
|
||||
const generateAssetsSprites = !!value.generateAssetsSprites
|
||||
const generateAssetsAnalyze = !!value.generateAssetsAnalyze
|
||||
const enrichPostworkEnabled = !!value.enrichPostworkEnabled
|
||||
|
||||
const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled
|
||||
const trainingDetectorEpochs = Math.max(
|
||||
@ -1026,6 +1030,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
generateAssetsTeaser,
|
||||
generateAssetsSprites,
|
||||
generateAssetsAnalyze,
|
||||
enrichPostworkEnabled,
|
||||
trainingRecognitionEnabled,
|
||||
trainingDetectorEpochs,
|
||||
}),
|
||||
@ -1104,6 +1109,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
`Aufräumen:\n` +
|
||||
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
|
||||
`• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` +
|
||||
`• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` +
|
||||
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
|
||||
`Fortfahren?`
|
||||
)
|
||||
@ -1871,6 +1877,18 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<LabeledSwitch
|
||||
checked={!!value.enrichPostworkEnabled}
|
||||
onChange={(checked) =>
|
||||
setValue((v) => ({
|
||||
...v,
|
||||
enrichPostworkEnabled: checked,
|
||||
}))
|
||||
}
|
||||
label="EnrichQ-Postwork im Hintergrund"
|
||||
description="Wenn aktiv, werden fehlende Analyse-/Enrich-Assets automatisch im Hintergrund nachgeholt. Wenn deaktiviert, werden keine neuen EnrichQ-Hintergrundjobs gestartet; manuelle Asset-Generierung bleibt möglich."
|
||||
/>
|
||||
|
||||
<LabeledSwitch
|
||||
checked={!!value.autoAddToDownloadList}
|
||||
onChange={(checked) =>
|
||||
|
||||
@ -33,15 +33,10 @@ type TabsProps = {
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
|
||||
/** Siehe Variants aus pasted.txt */
|
||||
variant?: TabsVariant
|
||||
|
||||
/**
|
||||
* Optional: In der pasted.txt sind Badges teils erst ab md sichtbar.
|
||||
* Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop).
|
||||
*/
|
||||
hideCountUntilMd?: boolean
|
||||
|
||||
desktopBreakpoint?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
@ -52,11 +47,27 @@ export default function Tabs({
|
||||
ariaLabel = 'Ansicht auswählen',
|
||||
variant = 'underline',
|
||||
hideCountUntilMd = false,
|
||||
desktopBreakpoint = 'sm',
|
||||
}: TabsProps) {
|
||||
if (!tabs?.length) return null
|
||||
|
||||
const current = tabs.find((t) => t.id === value) ?? tabs[0]
|
||||
|
||||
const mobileClassByBreakpoint = {
|
||||
sm: 'sm:hidden',
|
||||
md: 'md:hidden',
|
||||
lg: 'lg:hidden',
|
||||
} as const
|
||||
|
||||
const desktopClassByBreakpoint = {
|
||||
sm: 'hidden sm:block',
|
||||
md: 'hidden md:block',
|
||||
lg: 'hidden lg:block',
|
||||
} as const
|
||||
|
||||
const mobileWrapperClass = mobileClassByBreakpoint[desktopBreakpoint]
|
||||
const desktopWrapperClass = desktopClassByBreakpoint[desktopBreakpoint]
|
||||
|
||||
const mobileSelectClass = clsx(
|
||||
// entspricht den Beispielen (outline-1 + -outline-offset-1)
|
||||
'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500',
|
||||
@ -317,7 +328,7 @@ export default function Tabs({
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Mobile: Select + Chevron (wie Beispiele) */}
|
||||
<div className="grid grid-cols-1 sm:hidden">
|
||||
<div className={clsx('grid grid-cols-1', mobileWrapperClass)}>
|
||||
<select value={current.id} onChange={(e) => onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}>
|
||||
{tabs.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
@ -332,7 +343,7 @@ export default function Tabs({
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="hidden sm:block">{renderDesktop()}</div>
|
||||
<div className={desktopWrapperClass}>{renderDesktop()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4817,7 +4817,7 @@ export default function TrainingTab(props: {
|
||||
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
|
||||
|
||||
return (
|
||||
<div className="min-h-full lg:h-full lg:min-h-0 lg:overflow-hidden">
|
||||
<div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
|
||||
{loadingPreviewUrl ? (
|
||||
<img
|
||||
src={loadingPreviewUrl}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
/* frontend\src\index.css */
|
||||
|
||||
@import "tailwindcss";
|
||||
@import 'flag-icons/css/flag-icons.min.css';
|
||||
|
||||
@ -16,6 +18,35 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Mobile: Textauswahl/Callout bei Long-Press global verhindern */
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
button,
|
||||
a,
|
||||
[role="button"],
|
||||
.select-none-mobile {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Eingaben und explizit markierbarer Text bleiben auswählbar */
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[contenteditable="true"],
|
||||
.user-select-text {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Video.js Popups (Playback-Rate Menü, Volume-Slider) über eigene Overlays legen */
|
||||
.video-js {
|
||||
position: relative; /* schafft eine stabile Basis */
|
||||
|
||||
@ -257,11 +257,23 @@ export type RecorderSettingsState = {
|
||||
useMyFreeCamsWatcher?: boolean
|
||||
autoDeleteSmallDownloads?: boolean
|
||||
autoDeleteSmallDownloadsBelowMB?: number
|
||||
autoDeleteSmallDownloadsKeepFavorites?: boolean
|
||||
blurPreviews?: boolean
|
||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||
teaserAudio?: boolean
|
||||
lowDiskPauseBelowGB?: number
|
||||
enableNotifications?: boolean
|
||||
|
||||
generateAssetsMeta?: boolean
|
||||
generateAssetsThumb?: boolean
|
||||
generateAssetsTeaser?: boolean
|
||||
generateAssetsSprites?: boolean
|
||||
generateAssetsAnalyze?: boolean
|
||||
|
||||
enrichPostworkEnabled?: boolean
|
||||
|
||||
trainingRecognitionEnabled?: boolean
|
||||
trainingDetectorEpochs?: number
|
||||
}
|
||||
|
||||
export type JobEvent = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user