updated
This commit is contained in:
parent
cf86cdd868
commit
1775cb5acd
@ -1310,10 +1310,12 @@ func postworkAudioSidecarCandidatesForOutput(out string) []string {
|
||||
|
||||
// Explizite Fallbacks, falls canonicalAssetIDFromName anders normalisiert.
|
||||
if base != "" {
|
||||
candidates = append(candidates, filepath.Join(dir, base+".audio.m4s"))
|
||||
candidates = append(candidates, filepath.Join(dir, "."+base+".audio.m4s"))
|
||||
}
|
||||
|
||||
if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" {
|
||||
candidates = append(candidates, filepath.Join(dir, canonical+".audio.m4s"))
|
||||
candidates = append(candidates, filepath.Join(dir, "."+canonical+".audio.m4s"))
|
||||
}
|
||||
|
||||
|
||||
@ -747,10 +747,19 @@ func findPostworkAudioSidecarForTS(tsPath string) (string, bool) {
|
||||
|
||||
dir := filepath.Dir(tsPath)
|
||||
base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath))
|
||||
if base != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, base+".audio.m4s"),
|
||||
filepath.Join(dir, "."+base+".audio.m4s"),
|
||||
filepath.Join(dir, "."+canonicalAssetIDFromName(base)+".audio.m4s"),
|
||||
)
|
||||
}
|
||||
|
||||
if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(dir, canonical+".audio.m4s"),
|
||||
filepath.Join(dir, "."+canonical+".audio.m4s"),
|
||||
)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"ffmpegPath": "",
|
||||
"autoAddToDownloadList": true,
|
||||
"autoStartAddedDownloads": true,
|
||||
"enableConcurrentDownloadsLimit": true,
|
||||
"enableConcurrentDownloadsLimit": false,
|
||||
"maxConcurrentDownloads": 80,
|
||||
"useProviderApis": true,
|
||||
"useChaturbateApi": true,
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -29,9 +31,45 @@ func startPostworkLeftoverScanOnStartup() {
|
||||
time.Sleep(postworkLeftoverInitialDelay)
|
||||
|
||||
runPostworkLeftoverScan := func(reason string) {
|
||||
removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
jobID := newTaskID(postworkLeftoverTaskPrefix(reason))
|
||||
createCleanupJob(jobID, "Wartet…", cancel)
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Queued = false
|
||||
st.Running = true
|
||||
st.StartedAt = time.Now()
|
||||
st.Done = 0
|
||||
st.Total = 2
|
||||
st.CurrentFile = ""
|
||||
st.Text = postworkLeftoverRunningText(reason)
|
||||
st.Error = ""
|
||||
st.FinishedAt = nil
|
||||
})
|
||||
|
||||
removedMuxing, skippedMuxing, err := cleanupStaleMuxingTempFilesFromRecordDir(ctx)
|
||||
if err != nil {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Done = 1
|
||||
st.Total = 2
|
||||
st.Text = "Suche offene Postwork-Dateien…"
|
||||
st.CurrentFile = ""
|
||||
})
|
||||
|
||||
queued, skipped, err := enqueuePostworkLeftoversFromRecordDir(ctx)
|
||||
if err != nil {
|
||||
finishCleanupJob(jobID, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
summary := postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing)
|
||||
|
||||
queued, skipped := enqueuePostworkLeftoversFromRecordDir()
|
||||
if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 {
|
||||
appLogln(
|
||||
"🧩 [startup-postwork] leftover scan fertig:",
|
||||
@ -42,6 +80,14 @@ func startPostworkLeftoverScanOnStartup() {
|
||||
"skippedMuxing=", skippedMuxing,
|
||||
)
|
||||
}
|
||||
|
||||
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
|
||||
st.Done = 2
|
||||
st.Total = 2
|
||||
st.Text = summary
|
||||
st.CurrentFile = ""
|
||||
})
|
||||
finishCleanupJob(jobID, summary, nil)
|
||||
}
|
||||
|
||||
runPostworkLeftoverScan("startup")
|
||||
@ -55,7 +101,72 @@ func startPostworkLeftoverScanOnStartup() {
|
||||
}()
|
||||
}
|
||||
|
||||
func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||
func postworkLeftoverTaskPrefix(reason string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(reason)) {
|
||||
case "startup":
|
||||
return "cleanup-leftovers-startup"
|
||||
case "interval":
|
||||
return "cleanup-leftovers-hourly"
|
||||
default:
|
||||
return "cleanup-leftovers"
|
||||
}
|
||||
}
|
||||
|
||||
func postworkLeftoverRunningText(reason string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(reason)) {
|
||||
case "startup":
|
||||
return "Startup-Cleanup: prüfe Record-Reste…"
|
||||
case "interval":
|
||||
return "Stündlicher Cleanup: prüfe Record-Reste…"
|
||||
default:
|
||||
return "Prüfe Record-Reste…"
|
||||
}
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing int) string {
|
||||
parts := make([]string, 0, 4)
|
||||
|
||||
if queued > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Postwork eingereiht: %d", queued))
|
||||
}
|
||||
if removedMuxing > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Muxing-Temps gelöscht: %d", removedMuxing))
|
||||
}
|
||||
if skipped > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Postwork übersprungen: %d", skipped))
|
||||
}
|
||||
if skippedMuxing > 0 {
|
||||
parts = append(parts, fmt.Sprintf("Muxing-Temps übersprungen: %d", skippedMuxing))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return "Keine Record-Reste gefunden."
|
||||
}
|
||||
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func cleanupStaleMuxingTempFilesFromRecordDir(ctx context.Context) (removed int, skipped int, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
s := getSettings()
|
||||
|
||||
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
|
||||
@ -66,18 +177,22 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||
recordAbs = strings.TrimSpace(recordAbs)
|
||||
if recordAbs == "" {
|
||||
appLogln("⚠️ [startup-postwork] recordDir ist leer")
|
||||
return 0, 0
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(recordAbs)
|
||||
if err != nil {
|
||||
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
|
||||
return 0, 0
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, e := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return removed, skipped, err
|
||||
}
|
||||
|
||||
if e == nil || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
@ -123,7 +238,11 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !singleFileStable(path) {
|
||||
stable, err := singleFileStable(ctx, path)
|
||||
if err != nil {
|
||||
return removed, skipped, err
|
||||
}
|
||||
if !stable {
|
||||
skipped++
|
||||
appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name)
|
||||
continue
|
||||
@ -146,7 +265,7 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) {
|
||||
)
|
||||
}
|
||||
|
||||
return removed, skipped
|
||||
return removed, skipped, nil
|
||||
}
|
||||
|
||||
func isMuxingTempTSName(name string) bool {
|
||||
@ -160,10 +279,10 @@ func isMuxingTempTSName(name string) bool {
|
||||
return strings.HasSuffix(lowerName, ".muxing.ts")
|
||||
}
|
||||
|
||||
func singleFileStable(path string) bool {
|
||||
func singleFileStable(ctx context.Context, path string) (bool, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sizeOf := func(p string) (int64, bool) {
|
||||
@ -177,20 +296,26 @@ func singleFileStable(path string) bool {
|
||||
|
||||
size1, ok1 := sizeOf(path)
|
||||
if !ok1 {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
if err := sleepContext(ctx, 1500*time.Millisecond); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
size2, ok2 := sizeOf(path)
|
||||
if !ok2 {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return size1 == size2
|
||||
return size1 == size2, nil
|
||||
}
|
||||
|
||||
func enqueuePostworkLeftoversFromRecordDir(ctx context.Context) (queued int, skipped int, err error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
s := getSettings()
|
||||
|
||||
recordAbs, err := resolvePathRelativeToApp(s.RecordDir)
|
||||
@ -200,13 +325,13 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
recordAbs = strings.TrimSpace(recordAbs)
|
||||
if recordAbs == "" {
|
||||
appLogln("⚠️ [startup-postwork] recordDir ist leer")
|
||||
return 0, 0
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(recordAbs)
|
||||
if err != nil {
|
||||
appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err)
|
||||
return 0, 0
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@ -225,6 +350,10 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return queued, skipped, err
|
||||
}
|
||||
|
||||
if e == nil || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
@ -311,7 +440,11 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !postworkLeftoverFilesStable(tsPath, audioPath) {
|
||||
stable, err := postworkLeftoverFilesStable(ctx, tsPath, audioPath)
|
||||
if err != nil {
|
||||
return queued, skipped, err
|
||||
}
|
||||
if !stable {
|
||||
skip(name, "files not stable", filepath.Base(audioPath))
|
||||
continue
|
||||
}
|
||||
@ -323,7 +456,7 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
|
||||
}
|
||||
}
|
||||
|
||||
return queued, skipped
|
||||
return queued, skipped, nil
|
||||
}
|
||||
|
||||
func postworkLeftoverAlreadyTracked(tsPath string) bool {
|
||||
@ -373,11 +506,11 @@ func postworkLeftoverDoneMP4Exists(tsPath string) bool {
|
||||
return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0
|
||||
}
|
||||
|
||||
func postworkLeftoverFilesStable(tsPath string, audioPath string) bool {
|
||||
func postworkLeftoverFilesStable(ctx context.Context, tsPath string, audioPath string) (bool, error) {
|
||||
tsPath = strings.TrimSpace(tsPath)
|
||||
audioPath = strings.TrimSpace(audioPath)
|
||||
if tsPath == "" || audioPath == "" {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
sizeOf := func(p string) (int64, bool) {
|
||||
@ -391,18 +524,20 @@ func postworkLeftoverFilesStable(tsPath string, audioPath string) bool {
|
||||
ts1, ok1 := sizeOf(tsPath)
|
||||
audio1, ok2 := sizeOf(audioPath)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
if err := sleepContext(ctx, 1500*time.Millisecond); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
ts2, ok1 := sizeOf(tsPath)
|
||||
audio2, ok2 := sizeOf(audioPath)
|
||||
if !ok1 || !ok2 {
|
||||
return false
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return ts1 == ts2 && audio1 == audio2
|
||||
return ts1 == ts2 && audio1 == audio2, nil
|
||||
}
|
||||
|
||||
func enqueuePostworkLeftover(tsPath string, tsInfo os.FileInfo) bool {
|
||||
|
||||
@ -62,6 +62,33 @@ type cleanupTaskJob struct {
|
||||
var cleanupJobsMu sync.Mutex
|
||||
var cleanupJobs = map[string]*cleanupTaskJob{}
|
||||
|
||||
func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState {
|
||||
st := CleanupTaskState{
|
||||
ID: jobID,
|
||||
Queued: true,
|
||||
Running: false,
|
||||
Cancellable: cancel != nil,
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
Text: strings.TrimSpace(text),
|
||||
Error: "",
|
||||
CurrentFile: "",
|
||||
Done: 0,
|
||||
Total: 0,
|
||||
}
|
||||
|
||||
cleanupJobsMu.Lock()
|
||||
cleanupJobs[jobID] = &cleanupTaskJob{
|
||||
State: st,
|
||||
Cancel: cancel,
|
||||
}
|
||||
cleanupJobsMu.Unlock()
|
||||
|
||||
publishTaskState()
|
||||
return st
|
||||
}
|
||||
|
||||
func snapshotCleanupJobs() []CleanupTaskState {
|
||||
cleanupJobsMu.Lock()
|
||||
defer cleanupJobsMu.Unlock()
|
||||
@ -184,6 +211,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
ID: jobID,
|
||||
Queued: true,
|
||||
Running: false,
|
||||
Cancellable: true,
|
||||
QueuedAt: time.Now(),
|
||||
StartedAt: time.Time{},
|
||||
FinishedAt: nil,
|
||||
@ -467,16 +495,14 @@ func cleanupTSPathForAudioSidecar(audioPath string) string {
|
||||
|
||||
dir := filepath.Dir(audioPath)
|
||||
name := filepath.Base(audioPath)
|
||||
lowName := strings.ToLower(name)
|
||||
|
||||
if !strings.HasPrefix(name, ".") {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(name), ".audio.m4s") {
|
||||
if !strings.HasSuffix(lowName, ".audio.m4s") {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := strings.TrimPrefix(name, ".")
|
||||
base = strings.TrimSuffix(base, ".audio.m4s")
|
||||
base := name[:len(name)-len(".audio.m4s")]
|
||||
base = strings.TrimPrefix(base, ".")
|
||||
|
||||
if strings.TrimSpace(base) == "" {
|
||||
return ""
|
||||
@ -543,7 +569,7 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
|
||||
|
||||
if strings.HasSuffix(lowName, ".muxing.ts") {
|
||||
resp.DeletedMuxingTemps++
|
||||
} else if strings.HasPrefix(name, ".") && strings.HasSuffix(lowName, ".audio.m4s") {
|
||||
} else if strings.HasSuffix(lowName, ".audio.m4s") {
|
||||
resp.DeletedAudioOrphans++
|
||||
} else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||
resp.DeletedTSOrphans++
|
||||
@ -637,7 +663,7 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
|
||||
}
|
||||
|
||||
// Fall 1:
|
||||
// foo.ts existiert, aber .foo.audio.m4s fehlt.
|
||||
// foo.ts existiert, aber kein passendes foo.audio.m4s/.foo.audio.m4s.
|
||||
if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") {
|
||||
resp.ScannedFiles++
|
||||
|
||||
@ -650,8 +676,8 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
|
||||
}
|
||||
|
||||
// Fall 2:
|
||||
// .foo.audio.m4s existiert, aber foo.ts fehlt.
|
||||
if strings.HasPrefix(name, ".") && strings.HasSuffix(low, ".audio.m4s") {
|
||||
// foo.audio.m4s/.foo.audio.m4s existiert, aber foo.ts fehlt.
|
||||
if strings.HasSuffix(low, ".audio.m4s") {
|
||||
resp.ScannedFiles++
|
||||
|
||||
tsPath := cleanupTSPathForAudioSidecar(full)
|
||||
|
||||
@ -16,6 +16,7 @@ type CleanupTaskState struct {
|
||||
ID string `json:"id"`
|
||||
Queued bool `json:"queued"`
|
||||
Running bool `json:"running"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
QueuedAt time.Time `json:"queuedAt"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
|
||||
@ -38,6 +38,8 @@ import LastUpdatedText from './components/ui/LastUpdatedText'
|
||||
import TrainingTab from './components/ui/TrainingTab'
|
||||
|
||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||
const MODELS_OVERVIEW_DEBOUNCE_MS = 750
|
||||
const MODELS_OVERVIEW_COOLDOWN_MS = 5000
|
||||
|
||||
function normalizeCookies(obj: Record<string, string> | null | undefined): Record<string, string> {
|
||||
const input = obj ?? {}
|
||||
@ -215,6 +217,20 @@ function mergeStoredModel(
|
||||
}
|
||||
}
|
||||
|
||||
function storedModelShallowEqual(a?: StoredModel | null, b?: StoredModel | null) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)])
|
||||
for (const key of keys) {
|
||||
if (!Object.is((a as any)[key], (b as any)[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' {
|
||||
const s = String(v ?? '').trim().toLowerCase()
|
||||
|
||||
@ -1747,6 +1763,8 @@ export default function App() {
|
||||
const overviewLastSigRef = useRef('')
|
||||
const overviewLastAtRef = useRef(0)
|
||||
const overviewTimerRef = useRef<number | null>(null)
|
||||
const overviewAbortRef = useRef<AbortController | null>(null)
|
||||
const overviewPendingKeysRef = useRef<string[] | null>(null)
|
||||
|
||||
const loadAppModelsSnapshot = useCallback(async (keys: string[]) => {
|
||||
const uniqKeys = Array.from(
|
||||
@ -1760,30 +1778,51 @@ export default function App() {
|
||||
const sig = uniqKeys.join('|')
|
||||
const now = Date.now()
|
||||
|
||||
if (overviewInFlightRef.current) return
|
||||
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return
|
||||
if (overviewInFlightRef.current) {
|
||||
overviewPendingKeysRef.current = uniqKeys
|
||||
return
|
||||
}
|
||||
|
||||
if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return
|
||||
|
||||
overviewInFlightRef.current = true
|
||||
overviewLastSigRef.current = sig
|
||||
overviewLastAtRef.current = now
|
||||
overviewAbortRef.current?.abort()
|
||||
|
||||
const controller = new AbortController()
|
||||
overviewAbortRef.current = controller
|
||||
|
||||
try {
|
||||
const data = await apiJSON<AppModelsSnapshot>('/api/models/overview', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store' as any,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
keys: uniqKeys,
|
||||
includeWatched: true,
|
||||
}),
|
||||
})
|
||||
|
||||
setHeaderStats({
|
||||
setHeaderStats((prev) => {
|
||||
const next = {
|
||||
totalCount: Number(data?.totalCount ?? 0),
|
||||
onlineModelsCount: Number(data?.onlineModelsCount ?? 0),
|
||||
onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0),
|
||||
onlineFavCount: Number(data?.onlineFavCount ?? 0),
|
||||
onlineLikedCount: Number(data?.onlineLikedCount ?? 0),
|
||||
}
|
||||
|
||||
return (
|
||||
prev.totalCount === next.totalCount &&
|
||||
prev.onlineModelsCount === next.onlineModelsCount &&
|
||||
prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount &&
|
||||
prev.onlineFavCount === next.onlineFavCount &&
|
||||
prev.onlineLikedCount === next.onlineLikedCount
|
||||
)
|
||||
? prev
|
||||
: next
|
||||
})
|
||||
|
||||
const safeList = Array.isArray(data?.models) ? data.models : []
|
||||
@ -1791,17 +1830,40 @@ export default function App() {
|
||||
|
||||
setModelsByKey((prev) => {
|
||||
const next = { ...prev }
|
||||
let changed = false
|
||||
|
||||
for (const [k, incoming] of Object.entries(built)) {
|
||||
next[k] = mergeStoredModel(prev[k], incoming)
|
||||
const merged = mergeStoredModel(prev[k], incoming)
|
||||
if (!storedModelShallowEqual(prev[k], merged)) {
|
||||
next[k] = merged
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
return changed ? next : prev
|
||||
})
|
||||
} catch {
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') return
|
||||
// ignore
|
||||
} finally {
|
||||
overviewInFlightRef.current = false
|
||||
if (overviewAbortRef.current === controller) {
|
||||
overviewAbortRef.current = null
|
||||
}
|
||||
|
||||
const pendingKeys = overviewPendingKeysRef.current
|
||||
overviewPendingKeysRef.current = null
|
||||
|
||||
if (pendingKeys && pendingKeys.join('|') !== sig) {
|
||||
if (overviewTimerRef.current != null) {
|
||||
window.clearTimeout(overviewTimerRef.current)
|
||||
}
|
||||
|
||||
overviewTimerRef.current = window.setTimeout(() => {
|
||||
overviewTimerRef.current = null
|
||||
void loadAppModelsSnapshot(pendingKeys)
|
||||
}, MODELS_OVERVIEW_DEBOUNCE_MS)
|
||||
}
|
||||
}
|
||||
}, [buildModelsByKey])
|
||||
|
||||
@ -2172,7 +2234,7 @@ export default function App() {
|
||||
|
||||
overviewTimerRef.current = window.setTimeout(() => {
|
||||
void loadAppModelsSnapshot(requiredModelKeys)
|
||||
}, 250)
|
||||
}, MODELS_OVERVIEW_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
if (lastOverviewSigRef.current !== requiredModelKeysSig) {
|
||||
@ -2242,6 +2304,11 @@ export default function App() {
|
||||
window.clearTimeout(overviewTimerRef.current)
|
||||
overviewTimerRef.current = null
|
||||
}
|
||||
|
||||
overviewPendingKeysRef.current = null
|
||||
overviewAbortRef.current?.abort()
|
||||
overviewAbortRef.current = null
|
||||
overviewInFlightRef.current = false
|
||||
}
|
||||
}, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache])
|
||||
|
||||
|
||||
@ -144,6 +144,143 @@ function useMobilePreviewConstrained() {
|
||||
return constrained
|
||||
}
|
||||
|
||||
const TEASER_MISSING_TTL_MS = 5 * 60 * 1000
|
||||
const TEASER_MISSING_CACHE_MAX = 600
|
||||
const MAX_TEASER_PREWARMS = 4
|
||||
|
||||
type TeaserMissingEntry = {
|
||||
failedAt: number
|
||||
assetNonce: number
|
||||
}
|
||||
|
||||
const teaserMissingById = new Map<string, TeaserMissingEntry>()
|
||||
const teaserPrewarmSubscribers = new Set<() => void>()
|
||||
let activeTeaserPrewarms = 0
|
||||
|
||||
function teaserCacheKey(id: string) {
|
||||
return String(id ?? '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeAssetNonce(value: unknown) {
|
||||
const n = Number(value ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function pruneTeaserMissingCache(now = Date.now()) {
|
||||
for (const [key, entry] of teaserMissingById.entries()) {
|
||||
if (now - entry.failedAt > TEASER_MISSING_TTL_MS) {
|
||||
teaserMissingById.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (teaserMissingById.size <= TEASER_MISSING_CACHE_MAX) return
|
||||
|
||||
const oldKeys = Array.from(teaserMissingById.entries())
|
||||
.sort((a, b) => a[1].failedAt - b[1].failedAt)
|
||||
.slice(0, teaserMissingById.size - TEASER_MISSING_CACHE_MAX)
|
||||
.map(([key]) => key)
|
||||
|
||||
for (const key of oldKeys) teaserMissingById.delete(key)
|
||||
}
|
||||
|
||||
function isTeaserMissingCached(id: string, assetNonce: unknown) {
|
||||
const key = teaserCacheKey(id)
|
||||
if (!key) return false
|
||||
|
||||
const entry = teaserMissingById.get(key)
|
||||
if (!entry) return false
|
||||
|
||||
const now = Date.now()
|
||||
if (now - entry.failedAt > TEASER_MISSING_TTL_MS) {
|
||||
teaserMissingById.delete(key)
|
||||
return false
|
||||
}
|
||||
|
||||
if (entry.assetNonce !== normalizeAssetNonce(assetNonce)) {
|
||||
teaserMissingById.delete(key)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function markTeaserMissing(id: string, assetNonce: unknown) {
|
||||
const key = teaserCacheKey(id)
|
||||
if (!key) return
|
||||
|
||||
const now = Date.now()
|
||||
pruneTeaserMissingCache(now)
|
||||
teaserMissingById.set(key, {
|
||||
failedAt: now,
|
||||
assetNonce: normalizeAssetNonce(assetNonce),
|
||||
})
|
||||
}
|
||||
|
||||
function clearTeaserMissing(id: string) {
|
||||
const key = teaserCacheKey(id)
|
||||
if (!key) return
|
||||
teaserMissingById.delete(key)
|
||||
}
|
||||
|
||||
function notifyTeaserPrewarmSlotChanged() {
|
||||
for (const fn of teaserPrewarmSubscribers) fn()
|
||||
}
|
||||
|
||||
function tryAcquireTeaserPrewarmSlot() {
|
||||
if (activeTeaserPrewarms >= MAX_TEASER_PREWARMS) return false
|
||||
activeTeaserPrewarms += 1
|
||||
return true
|
||||
}
|
||||
|
||||
function releaseTeaserPrewarmSlot() {
|
||||
if (activeTeaserPrewarms > 0) {
|
||||
activeTeaserPrewarms -= 1
|
||||
}
|
||||
notifyTeaserPrewarmSlotChanged()
|
||||
}
|
||||
|
||||
function useTeaserPrewarmSlot(enabled: boolean, key: string) {
|
||||
const [hasSlot, setHasSlot] = useState(false)
|
||||
const hasSlotRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !key) {
|
||||
if (hasSlotRef.current) {
|
||||
hasSlotRef.current = false
|
||||
releaseTeaserPrewarmSlot()
|
||||
}
|
||||
setHasSlot(false)
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
|
||||
const acquire = () => {
|
||||
if (!alive || hasSlotRef.current) return
|
||||
if (!tryAcquireTeaserPrewarmSlot()) return
|
||||
|
||||
hasSlotRef.current = true
|
||||
setHasSlot(true)
|
||||
}
|
||||
|
||||
teaserPrewarmSubscribers.add(acquire)
|
||||
acquire()
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
teaserPrewarmSubscribers.delete(acquire)
|
||||
|
||||
if (hasSlotRef.current) {
|
||||
hasSlotRef.current = false
|
||||
releaseTeaserPrewarmSlot()
|
||||
}
|
||||
setHasSlot(false)
|
||||
}
|
||||
}, [enabled, key])
|
||||
|
||||
return hasSlot
|
||||
}
|
||||
|
||||
export default function FinishedVideoPreview({
|
||||
job,
|
||||
getFileName,
|
||||
@ -172,6 +309,7 @@ export default function FinishedVideoPreview({
|
||||
inlineNonce = 0,
|
||||
inlineControls = false,
|
||||
inlineLoop = true,
|
||||
assetNonce = 0,
|
||||
muted = DEFAULT_INLINE_MUTED,
|
||||
popoverMuted = DEFAULT_INLINE_MUTED,
|
||||
alwaysLoadStill = false,
|
||||
@ -352,6 +490,8 @@ export default function FinishedVideoPreview({
|
||||
|
||||
const [teaserReady, setTeaserReady] = useState(false)
|
||||
const [teaserOk, setTeaserOk] = useState(true)
|
||||
const [teaserCacheVersion, setTeaserCacheVersion] = useState(0)
|
||||
const [teaserPrewarmDone, setTeaserPrewarmDone] = useState(false)
|
||||
|
||||
// ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent)
|
||||
const emittedDurationRef = useRef<string>('')
|
||||
@ -619,13 +759,14 @@ export default function FinishedVideoPreview({
|
||||
|
||||
useEffect(() => {
|
||||
setTeaserReady(false)
|
||||
setTeaserOk(true)
|
||||
setTeaserPrewarmDone(false)
|
||||
setTeaserOk(!isTeaserMissingCached(previewId, assetNonce))
|
||||
|
||||
emittedDurationRef.current = ''
|
||||
emittedResolutionRef.current = ''
|
||||
teaserSoundForcedRef.current = false
|
||||
setMetaLoaded(false)
|
||||
}, [previewId])
|
||||
}, [previewId, assetNonce])
|
||||
|
||||
useEffect(() => {
|
||||
const onRelease = (ev: any) => {
|
||||
@ -736,10 +877,34 @@ export default function FinishedVideoPreview({
|
||||
return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}`
|
||||
}, [previewId, thumbTimeSec])
|
||||
|
||||
const teaserKnownMissing = useMemo(
|
||||
() => isTeaserMissingCached(previewId, assetNonce),
|
||||
[previewId, assetNonce, teaserCacheVersion]
|
||||
)
|
||||
|
||||
const teaserSrc = useMemo(() => {
|
||||
if (!previewId) return ''
|
||||
if (teaserKnownMissing) return ''
|
||||
return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1`
|
||||
}, [previewId])
|
||||
}, [previewId, teaserKnownMissing])
|
||||
|
||||
const markTeaserAvailable = useCallback(() => {
|
||||
if (isTeaserMissingCached(previewId, assetNonce)) {
|
||||
clearTeaserMissing(previewId)
|
||||
setTeaserCacheVersion((v) => v + 1)
|
||||
}
|
||||
setTeaserOk(true)
|
||||
setTeaserReady(true)
|
||||
setTeaserPrewarmDone(true)
|
||||
}, [previewId, assetNonce])
|
||||
|
||||
const markTeaserUnavailable = useCallback(() => {
|
||||
markTeaserMissing(previewId, assetNonce)
|
||||
setTeaserCacheVersion((v) => v + 1)
|
||||
setTeaserOk(false)
|
||||
setTeaserReady(false)
|
||||
setTeaserPrewarmDone(true)
|
||||
}, [previewId, assetNonce])
|
||||
|
||||
// --- Inline Video sichtbar?
|
||||
const showingInlineVideo =
|
||||
@ -864,11 +1029,6 @@ export default function FinishedVideoPreview({
|
||||
lastMountedRef.current.clips = null
|
||||
}, [previewId])
|
||||
|
||||
if (!videoSrc) {
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
}
|
||||
|
||||
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
|
||||
const shouldLoadAssets =
|
||||
shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive
|
||||
|
||||
@ -877,10 +1037,25 @@ export default function FinishedVideoPreview({
|
||||
!mobilePreviewConstrained &&
|
||||
animatedMode === 'teaser' &&
|
||||
Boolean(teaserSrc) &&
|
||||
!teaserKnownMissing &&
|
||||
!teaserPrewarmDone &&
|
||||
!showingInlineVideo &&
|
||||
shouldPreloadAnimatedAssets &&
|
||||
animatedTrigger !== 'hover'
|
||||
|
||||
const teaserPrewarmWanted =
|
||||
teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser')
|
||||
|
||||
const teaserPrewarmHasSlot = useTeaserPrewarmSlot(
|
||||
teaserPrewarmWanted,
|
||||
`${previewId}:${normalizeAssetNonce(assetNonce)}`
|
||||
)
|
||||
|
||||
if (!videoSrc) {
|
||||
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
|
||||
}
|
||||
|
||||
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
|
||||
const showStillImage =
|
||||
shouldLoadStill &&
|
||||
!showingInlineVideo &&
|
||||
@ -1464,26 +1639,17 @@ export default function FinishedVideoPreview({
|
||||
) : null}
|
||||
|
||||
{/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */}
|
||||
{!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (
|
||||
{teaserPrewarmHasSlot ? (
|
||||
<video
|
||||
key={`teaser-prewarm-${previewId}`}
|
||||
key={`teaser-prewarm-${previewId}-${normalizeAssetNonce(assetNonce)}`}
|
||||
src={teaserSrc}
|
||||
className="hidden"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onLoadedData={() => {
|
||||
setTeaserOk(true)
|
||||
setTeaserReady(true)
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
setTeaserOk(true)
|
||||
setTeaserReady(true)
|
||||
}}
|
||||
onError={() => {
|
||||
setTeaserOk(false)
|
||||
setTeaserReady(false)
|
||||
}}
|
||||
onLoadedData={markTeaserAvailable}
|
||||
onCanPlay={markTeaserAvailable}
|
||||
onError={markTeaserUnavailable}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -1510,18 +1676,9 @@ export default function FinishedVideoPreview({
|
||||
loop
|
||||
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
|
||||
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
|
||||
onLoadedData={() => {
|
||||
setTeaserOk(true)
|
||||
setTeaserReady(true)
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
setTeaserOk(true)
|
||||
setTeaserReady(true)
|
||||
}}
|
||||
onError={() => {
|
||||
setTeaserOk(false)
|
||||
setTeaserReady(false)
|
||||
}}
|
||||
onLoadedData={markTeaserAvailable}
|
||||
onCanPlay={markTeaserAvailable}
|
||||
onError={markTeaserUnavailable}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@ -484,6 +484,7 @@ function mapCheckVideosJobToTask(job: any): TaskItem {
|
||||
}
|
||||
|
||||
function mapCleanupJobToTask(job: any): TaskItem {
|
||||
const rawId = String(job?.id || '')
|
||||
const err = String(job?.error || '').trim()
|
||||
let status: TaskItem['status'] = 'idle'
|
||||
|
||||
@ -493,20 +494,29 @@ function mapCleanupJobToTask(job: any): TaskItem {
|
||||
else if (job?.finishedAt) status = 'done'
|
||||
|
||||
return {
|
||||
id: cleanupTaskDomId(String(job?.id || '')),
|
||||
id: cleanupTaskDomId(rawId),
|
||||
status,
|
||||
title: 'Aufräumen',
|
||||
title: cleanupTaskTitle(rawId),
|
||||
text: String(job?.text || job?.currentFile || ''),
|
||||
done: Number(job?.done ?? 0),
|
||||
total: Number(job?.total ?? 0),
|
||||
err: status === 'error' ? err : undefined,
|
||||
cancellable: status === 'queued' || status === 'running',
|
||||
cancellable: job?.cancellable !== false && (status === 'queued' || status === 'running'),
|
||||
fading: false,
|
||||
queuedAtMs: parseTaskTimeMs(job?.queuedAt),
|
||||
startedAtMs: parseTaskTimeMs(job?.startedAt),
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupTaskTitle(rawId: string) {
|
||||
const id = String(rawId || '').trim().toLowerCase()
|
||||
|
||||
if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup'
|
||||
if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup'
|
||||
if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen'
|
||||
return 'Aufräumen'
|
||||
}
|
||||
|
||||
const SETTINGS_RATING_STAR_PATH =
|
||||
'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z'
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user