3124 lines
71 KiB
Go
3124 lines
71 KiB
Go
// backend/record.go
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
var trashCleanupMu sync.Mutex
|
|
|
|
// ---------------- Types ----------------
|
|
|
|
type prepareSplitReq struct {
|
|
Output string `json:"output"`
|
|
Goal string `json:"goal,omitempty"` // z.B. "highlights"
|
|
}
|
|
|
|
type prepareSplitResp struct {
|
|
OK bool `json:"ok"`
|
|
AssetsReady bool `json:"assetsReady"`
|
|
AnalyzeReady bool `json:"analyzeReady"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type RecordRequest struct {
|
|
URL string `json:"url"`
|
|
Cookie string `json:"cookie,omitempty"`
|
|
UserAgent string `json:"userAgent,omitempty"`
|
|
Hidden bool `json:"hidden,omitempty"`
|
|
|
|
// Intern: Resume nach private/hidden/away darf das normale Download-Limit ignorieren.
|
|
IgnoreConcurrentLimit bool `json:"-"`
|
|
}
|
|
|
|
type doneListResponse struct {
|
|
Items []*RecordJob `json:"items"`
|
|
TotalCount int `json:"totalCount"`
|
|
Page int `json:"page,omitempty"`
|
|
PageSize int `json:"pageSize,omitempty"`
|
|
}
|
|
|
|
type previewSpriteMetaResp struct {
|
|
Exists bool `json:"exists"`
|
|
Path string `json:"path,omitempty"`
|
|
Count int `json:"count,omitempty"`
|
|
Cols int `json:"cols,omitempty"`
|
|
Rows int `json:"rows,omitempty"`
|
|
StepSeconds float64 `json:"stepSeconds,omitempty"`
|
|
}
|
|
|
|
type finishedPhaseTruth struct {
|
|
MetaReady bool `json:"metaReady"`
|
|
TeaserReady bool `json:"teaserReady"`
|
|
ThumbReady bool `json:"thumbReady"`
|
|
SpritesReady bool `json:"spritesReady"`
|
|
AnalyzeReady bool `json:"analyzeReady"`
|
|
}
|
|
|
|
type doneMetaFileResp struct {
|
|
File string `json:"file"`
|
|
MetaExists bool `json:"metaExists"`
|
|
Meta *videoMeta `json:"meta,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type doneMetaResp struct {
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type durationReq struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
type durationItem struct {
|
|
File string `json:"file"`
|
|
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type undoDeleteToken struct {
|
|
Trash string `json:"trash"` // basename in .trash (legacy/optional)
|
|
RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model"
|
|
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
|
|
From string `json:"from,omitempty"` // "done" oder "keep" für Undo-Count/UI
|
|
}
|
|
|
|
type trashMeta struct {
|
|
Token string `json:"token"`
|
|
TrashName string `json:"trashName"`
|
|
RelDir string `json:"relDir"`
|
|
File string `json:"file"`
|
|
From string `json:"from,omitempty"`
|
|
DeletedAt int64 `json:"deletedAt,omitempty"`
|
|
}
|
|
|
|
type bulkFilesRequest struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
type concurrentDownloadsStatusResp struct {
|
|
Enabled bool `json:"enabled"`
|
|
Max int `json:"max"`
|
|
Active int `json:"active"`
|
|
Reached bool `json:"reached"`
|
|
}
|
|
|
|
func recordConcurrentDownloadsStatus(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
|
|
enabled, max, active, reached := concurrentDownloadsLimitState()
|
|
|
|
respondJSON(w, concurrentDownloadsStatusResp{
|
|
Enabled: enabled,
|
|
Max: max,
|
|
Active: active,
|
|
Reached: reached,
|
|
})
|
|
}
|
|
|
|
func encodeUndoDeleteToken(t undoDeleteToken) (string, error) {
|
|
b, err := json.Marshal(t)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
func decodeUndoDeleteToken(raw string) (undoDeleteToken, error) {
|
|
var t undoDeleteToken
|
|
b, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return t, err
|
|
}
|
|
if err := json.Unmarshal(b, &t); err != nil {
|
|
return t, err
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// ---------------- Small response helpers ----------------
|
|
|
|
func respondJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func mustMethod(w http.ResponseWriter, r *http.Request, methods ...string) bool {
|
|
for _, m := range methods {
|
|
if r.Method == m {
|
|
return true
|
|
}
|
|
}
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
return false
|
|
}
|
|
|
|
func recordPrepareSplit(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
var req prepareSplitReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "ungültiger body: "+err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
|
|
defer cancel()
|
|
|
|
res, err := prepareVideoForSplit(ctx, req.Output, "", req.Goal)
|
|
if err != nil {
|
|
respondJSON(w, prepareSplitResp{
|
|
OK: false,
|
|
AssetsReady: false,
|
|
AnalyzeReady: false,
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
respondJSON(w, prepareSplitResp{
|
|
OK: true,
|
|
AssetsReady: res.AssetsReady,
|
|
AnalyzeReady: res.AnalyzeReady,
|
|
})
|
|
}
|
|
|
|
func handleDeleteMany(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req bulkFilesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid json body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Files) == 0 {
|
|
http.Error(w, "missing files", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
keepRoot := filepath.Join(doneAbs, "keep")
|
|
|
|
type itemResult struct {
|
|
File string `json:"file"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
}
|
|
|
|
results := make([]itemResult, 0, len(req.Files))
|
|
deletedCount := 0
|
|
seen := make(map[string]struct{}, len(req.Files))
|
|
|
|
for _, raw := range req.Files {
|
|
file := strings.TrimSpace(raw)
|
|
if file == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[file]; ok {
|
|
continue
|
|
}
|
|
seen[file] = struct{}{}
|
|
|
|
if !isAllowedVideoExt(file) {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "nicht erlaubt",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// laufende/queued Nacharbeit abbrechen + Datei freigeben
|
|
releaseFileForMutation(file)
|
|
|
|
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "datei nicht gefunden",
|
|
})
|
|
continue
|
|
}
|
|
if fi != nil && fi.IsDir() {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "ist ein verzeichnis",
|
|
From: from,
|
|
})
|
|
continue
|
|
}
|
|
|
|
if err := removeWithRetry(target); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "datei wird gerade verwendet",
|
|
From: from,
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: err.Error(),
|
|
From: from,
|
|
})
|
|
continue
|
|
}
|
|
|
|
purgeDurationCacheForPath(target)
|
|
removeJobsByOutputBasename(file)
|
|
|
|
// generated assets aufräumen
|
|
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
|
|
if strings.TrimSpace(id) != "" {
|
|
removeGeneratedForID(id)
|
|
}
|
|
|
|
// optional: leeren Keep-Unterordner nicht hart notwendig, aber sauber
|
|
if from == "keep" {
|
|
dir := filepath.Dir(target)
|
|
if strings.HasPrefix(strings.ToLower(filepath.Clean(dir)), strings.ToLower(filepath.Clean(keepRoot))) {
|
|
_ = os.Remove(dir)
|
|
}
|
|
}
|
|
|
|
deletedCount++
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: true,
|
|
From: from,
|
|
})
|
|
}
|
|
|
|
if deletedCount > 0 {
|
|
notifyDoneChanged()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": deletedCount > 0,
|
|
"deleted": deletedCount,
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
func handleKeepMany(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req bulkFilesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid json body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if len(req.Files) == 0 {
|
|
http.Error(w, "missing files", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
keepRoot := filepath.Join(doneAbs, "keep")
|
|
if err := os.MkdirAll(keepRoot, 0o755); err != nil {
|
|
http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type itemResult struct {
|
|
File string `json:"file"`
|
|
NewFile string `json:"newFile,omitempty"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
results := make([]itemResult, 0, len(req.Files))
|
|
keptCount := 0
|
|
seen := make(map[string]struct{}, len(req.Files))
|
|
|
|
for _, raw := range req.Files {
|
|
file := strings.TrimSpace(raw)
|
|
if file == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[file]; ok {
|
|
continue
|
|
}
|
|
seen[file] = struct{}{}
|
|
|
|
if !isAllowedVideoExt(file) {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "nicht erlaubt",
|
|
})
|
|
continue
|
|
}
|
|
|
|
releaseFileForMutation(file)
|
|
|
|
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "datei nicht gefunden",
|
|
})
|
|
continue
|
|
}
|
|
|
|
// Bereits in keep -> nichts tun, aber als ok zurückgeben
|
|
if from == "keep" {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
NewFile: file,
|
|
OK: true,
|
|
})
|
|
continue
|
|
}
|
|
|
|
if fi == nil || fi.IsDir() {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "ist ein verzeichnis",
|
|
})
|
|
continue
|
|
}
|
|
|
|
modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs)
|
|
dstDir := keepRoot
|
|
if modelKey != "" {
|
|
dstDir = filepath.Join(keepRoot, modelKey)
|
|
}
|
|
|
|
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "keep subdir erstellen fehlgeschlagen: " + err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
dst, err := uniqueDestPath(dstDir, file)
|
|
if err != nil {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "zielname nicht verfügbar: " + err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: "datei wird gerade verwendet",
|
|
})
|
|
continue
|
|
}
|
|
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
OK: false,
|
|
Error: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
|
|
keptCount++
|
|
results = append(results, itemResult{
|
|
File: file,
|
|
NewFile: filepath.Base(dst),
|
|
OK: true,
|
|
})
|
|
}
|
|
|
|
if keptCount > 0 {
|
|
notifyDoneChanged()
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"ok": keptCount > 0,
|
|
"kept": keptCount,
|
|
"results": results,
|
|
})
|
|
}
|
|
|
|
// ---------------- Preview sprite truth (shared) ----------------
|
|
|
|
type previewSpriteMetaFileInfo struct {
|
|
Count int
|
|
Cols int
|
|
Rows int
|
|
StepSeconds float64
|
|
}
|
|
|
|
// Best-effort parsing "previewSprite" from meta.json
|
|
func readPreviewSpriteMetaFromMetaFile(metaPath string) (previewSpriteMetaFileInfo, bool) {
|
|
var out previewSpriteMetaFileInfo
|
|
|
|
b, err := os.ReadFile(metaPath)
|
|
if err != nil || len(b) == 0 {
|
|
return out, false
|
|
}
|
|
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err != nil {
|
|
return out, false
|
|
}
|
|
|
|
previewMap, ok := m["preview"].(map[string]any)
|
|
if !ok || previewMap == nil {
|
|
return out, false
|
|
}
|
|
|
|
ps, ok := previewMap["sprite"].(map[string]any)
|
|
if !ok || ps == nil {
|
|
return out, false
|
|
}
|
|
|
|
intFromAny := func(v any) (int, bool) {
|
|
switch x := v.(type) {
|
|
case int:
|
|
return x, true
|
|
case int8:
|
|
return int(x), true
|
|
case int16:
|
|
return int(x), true
|
|
case int32:
|
|
return int(x), true
|
|
case int64:
|
|
return int(x), true
|
|
case uint:
|
|
return int(x), true
|
|
case uint8:
|
|
return int(x), true
|
|
case uint16:
|
|
return int(x), true
|
|
case uint32:
|
|
return int(x), true
|
|
case uint64:
|
|
return int(x), true
|
|
case float32:
|
|
return int(x), true
|
|
case float64:
|
|
return int(x), true
|
|
case json.Number:
|
|
if i, err := x.Int64(); err == nil {
|
|
return int(i), true
|
|
}
|
|
if f, err := x.Float64(); err == nil {
|
|
return int(f), true
|
|
}
|
|
case string:
|
|
s := strings.TrimSpace(x)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
if i, err := strconv.Atoi(s); err == nil {
|
|
return i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
floatFromAny := func(v any) (float64, bool) {
|
|
switch x := v.(type) {
|
|
case float32:
|
|
return float64(x), true
|
|
case float64:
|
|
return x, true
|
|
case int:
|
|
return float64(x), true
|
|
case int8:
|
|
return float64(x), true
|
|
case int16:
|
|
return float64(x), true
|
|
case int32:
|
|
return float64(x), true
|
|
case int64:
|
|
return float64(x), true
|
|
case uint:
|
|
return float64(x), true
|
|
case uint8:
|
|
return float64(x), true
|
|
case uint16:
|
|
return float64(x), true
|
|
case uint32:
|
|
return float64(x), true
|
|
case uint64:
|
|
return float64(x), true
|
|
case json.Number:
|
|
if f, err := x.Float64(); err == nil {
|
|
return f, true
|
|
}
|
|
case string:
|
|
s := strings.TrimSpace(x)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return f, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
if n, ok := intFromAny(ps["count"]); ok && n > 0 {
|
|
out.Count = n
|
|
} else if n, ok := intFromAny(ps["frames"]); ok && n > 0 {
|
|
out.Count = n
|
|
} else if n, ok := intFromAny(ps["imageCount"]); ok && n > 0 {
|
|
out.Count = n
|
|
}
|
|
|
|
if n, ok := intFromAny(ps["cols"]); ok && n > 0 {
|
|
out.Cols = n
|
|
}
|
|
if n, ok := intFromAny(ps["rows"]); ok && n > 0 {
|
|
out.Rows = n
|
|
}
|
|
|
|
if f, ok := floatFromAny(ps["stepSeconds"]); ok && f > 0 {
|
|
out.StepSeconds = f
|
|
} else if f, ok := floatFromAny(ps["step"]); ok && f > 0 {
|
|
out.StepSeconds = f
|
|
} else if f, ok := floatFromAny(ps["intervalSeconds"]); ok && f > 0 {
|
|
out.StepSeconds = f
|
|
}
|
|
|
|
if out.Count > 0 || (out.Cols > 0 && out.Rows > 0) {
|
|
return out, true
|
|
}
|
|
return out, false
|
|
}
|
|
|
|
func previewSpriteTruthForID(id string) previewSpriteMetaResp {
|
|
out := previewSpriteMetaResp{Exists: false}
|
|
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
|
return out
|
|
}
|
|
|
|
_, _, _, spriteFile, metaPath, err := assetPathsForID(id)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
|
|
fi, err := os.Stat(spriteFile)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return out
|
|
}
|
|
|
|
out.Exists = true
|
|
out.Path = "/api/preview-sprite/" + url.PathEscape(id)
|
|
|
|
if ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath); ok {
|
|
if ps.Count > 0 {
|
|
out.Count = ps.Count
|
|
}
|
|
if ps.Cols > 0 {
|
|
out.Cols = ps.Cols
|
|
}
|
|
if ps.Rows > 0 {
|
|
out.Rows = ps.Rows
|
|
}
|
|
if ps.StepSeconds > 0 {
|
|
out.StepSeconds = ps.StepSeconds
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func numberFromAny(v any) (float64, bool) {
|
|
switch x := v.(type) {
|
|
case float32:
|
|
return float64(x), true
|
|
case float64:
|
|
return x, true
|
|
case int:
|
|
return float64(x), true
|
|
case int8:
|
|
return float64(x), true
|
|
case int16:
|
|
return float64(x), true
|
|
case int32:
|
|
return float64(x), true
|
|
case int64:
|
|
return float64(x), true
|
|
case uint:
|
|
return float64(x), true
|
|
case uint8:
|
|
return float64(x), true
|
|
case uint16:
|
|
return float64(x), true
|
|
case uint32:
|
|
return float64(x), true
|
|
case uint64:
|
|
return float64(x), true
|
|
case json.Number:
|
|
if f, err := x.Float64(); err == nil {
|
|
return f, true
|
|
}
|
|
case string:
|
|
s := strings.TrimSpace(x)
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
return f, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func finishedPhaseTruthForID(id string) finishedPhaseTruth {
|
|
var out finishedPhaseTruth
|
|
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") {
|
|
return out
|
|
}
|
|
|
|
_, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id)
|
|
if err != nil || strings.TrimSpace(metaPath) == "" {
|
|
return out
|
|
}
|
|
|
|
b, err := os.ReadFile(metaPath)
|
|
if err == nil && len(b) > 0 {
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err == nil {
|
|
media, _ := m["media"].(map[string]any)
|
|
if media != nil {
|
|
if d, ok := numberFromAny(media["durationSeconds"]); ok && d > 0 {
|
|
video, _ := media["video"].(map[string]any)
|
|
if video != nil {
|
|
if w, wok := numberFromAny(video["width"]); wok && w > 0 {
|
|
if h, hok := numberFromAny(video["height"]); hok && h > 0 {
|
|
out.MetaReady = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
preview, _ := m["preview"].(map[string]any)
|
|
if preview != nil {
|
|
if clips, ok := preview["clips"].([]any); ok && len(clips) > 0 {
|
|
out.TeaserReady = true
|
|
}
|
|
|
|
if thumb, ok := preview["thumb"].(map[string]any); ok && thumb != nil {
|
|
if exists, ok := thumb["exists"].(bool); ok && exists {
|
|
out.ThumbReady = true
|
|
}
|
|
if p, ok := thumb["path"].(string); ok && strings.TrimSpace(p) != "" {
|
|
out.ThumbReady = true
|
|
}
|
|
}
|
|
|
|
if sprite, ok := preview["sprite"].(map[string]any); ok && sprite != nil {
|
|
if p, ok := sprite["path"].(string); ok && strings.TrimSpace(p) != "" {
|
|
out.SpritesReady = true
|
|
}
|
|
}
|
|
}
|
|
|
|
analysis, _ := m["analysis"].(map[string]any)
|
|
if analysis != nil {
|
|
if highlights, ok := analysis["highlights"].(map[string]any); ok && highlights != nil {
|
|
rawHits, hasHits := highlights["hits"].([]any)
|
|
rawSegs, hasSegs := highlights["segments"].([]any)
|
|
rawRating, hasRating := highlights["rating"].(map[string]any)
|
|
|
|
if (hasHits && len(rawHits) > 0) ||
|
|
(hasSegs && len(rawSegs) > 0) ||
|
|
(hasRating && len(rawRating) > 0) {
|
|
out.AnalyzeReady = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !out.ThumbReady {
|
|
if fi, err := os.Stat(thumbPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
|
out.ThumbReady = true
|
|
}
|
|
}
|
|
|
|
if !out.SpritesReady {
|
|
if fi, err := os.Stat(spritePath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
|
out.SpritesReady = true
|
|
}
|
|
}
|
|
|
|
if !out.TeaserReady {
|
|
if fi, err := os.Stat(previewPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
|
|
out.TeaserReady = true
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
|
|
ps := previewSpriteTruthForID(id)
|
|
if !ps.Exists {
|
|
return
|
|
}
|
|
|
|
if resp.Meta == nil {
|
|
resp.Meta = &videoMeta{}
|
|
}
|
|
|
|
resp.Meta.Preview.Sprite = &previewSpriteMeta{
|
|
Path: ps.Path,
|
|
Count: ps.Count,
|
|
Cols: ps.Cols,
|
|
Rows: ps.Rows,
|
|
StepSeconds: ps.StepSeconds,
|
|
}
|
|
}
|
|
|
|
// robust meta setter into RecordJob.Meta (any/string/[]byte/typed map)
|
|
func metaMapFromAny(v any) map[string]any {
|
|
out := map[string]any{}
|
|
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return out
|
|
|
|
case map[string]any:
|
|
for k, val := range x {
|
|
out[k] = val
|
|
}
|
|
return out
|
|
|
|
case string:
|
|
s := strings.TrimSpace(x)
|
|
if s == "" {
|
|
return out
|
|
}
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(s))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err == nil && m != nil {
|
|
return m
|
|
}
|
|
return out
|
|
|
|
case []byte:
|
|
if len(x) == 0 {
|
|
return out
|
|
}
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(x)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err == nil && m != nil {
|
|
return m
|
|
}
|
|
return out
|
|
|
|
case json.RawMessage:
|
|
if len(x) == 0 {
|
|
return out
|
|
}
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(x)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err == nil && m != nil {
|
|
return m
|
|
}
|
|
return out
|
|
|
|
default:
|
|
b, err := json.Marshal(x)
|
|
if err != nil || len(b) == 0 {
|
|
return out
|
|
}
|
|
var m map[string]any
|
|
dec := json.NewDecoder(strings.NewReader(string(b)))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&m); err == nil && m != nil {
|
|
return m
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
|
|
func setStructFieldJSONMap(fv reflect.Value, m map[string]any) {
|
|
if !fv.IsValid() || !fv.CanSet() {
|
|
return
|
|
}
|
|
|
|
b, err := json.Marshal(m)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
switch fv.Kind() {
|
|
case reflect.Interface:
|
|
fv.Set(reflect.ValueOf(m))
|
|
return
|
|
|
|
case reflect.String:
|
|
fv.SetString(string(b))
|
|
return
|
|
|
|
case reflect.Slice:
|
|
if fv.Type().Elem().Kind() == reflect.Uint8 {
|
|
fv.SetBytes(b)
|
|
return
|
|
}
|
|
}
|
|
|
|
ptr := reflect.New(fv.Type())
|
|
if err := json.Unmarshal(b, ptr.Interface()); err == nil {
|
|
fv.Set(ptr.Elem())
|
|
}
|
|
}
|
|
|
|
func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) {
|
|
if j == nil {
|
|
return
|
|
}
|
|
|
|
outPath := strings.TrimSpace(j.Output)
|
|
if outPath == "" {
|
|
return
|
|
}
|
|
id := canonicalAssetIDFromName(outPath)
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
ps := previewSpriteTruthForID(id)
|
|
|
|
rv := reflect.ValueOf(j)
|
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
|
return
|
|
}
|
|
sv := rv.Elem()
|
|
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
|
|
fv := sv.FieldByName("Meta")
|
|
if !fv.IsValid() || !fv.CanSet() {
|
|
return
|
|
}
|
|
|
|
var raw any
|
|
switch fv.Kind() {
|
|
case reflect.Interface:
|
|
if fv.IsNil() {
|
|
raw = nil
|
|
} else {
|
|
raw = fv.Interface()
|
|
}
|
|
default:
|
|
raw = fv.Interface()
|
|
}
|
|
|
|
meta := metaMapFromAny(raw)
|
|
if meta == nil {
|
|
meta = map[string]any{}
|
|
}
|
|
|
|
psMap := map[string]any{"exists": ps.Exists}
|
|
if ps.Exists {
|
|
psMap["path"] = ps.Path
|
|
if ps.Count > 0 {
|
|
psMap["count"] = ps.Count
|
|
}
|
|
if ps.Cols > 0 {
|
|
psMap["cols"] = ps.Cols
|
|
}
|
|
if ps.Rows > 0 {
|
|
psMap["rows"] = ps.Rows
|
|
}
|
|
if ps.StepSeconds > 0 {
|
|
psMap["stepSeconds"] = ps.StepSeconds
|
|
}
|
|
}
|
|
|
|
previewMap, _ := meta["preview"].(map[string]any)
|
|
if previewMap == nil {
|
|
previewMap = map[string]any{}
|
|
}
|
|
previewMap["sprite"] = psMap
|
|
meta["preview"] = previewMap
|
|
|
|
setStructFieldJSONMap(fv, meta)
|
|
}
|
|
|
|
func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) {
|
|
if j == nil {
|
|
return
|
|
}
|
|
|
|
outPath := strings.TrimSpace(j.Output)
|
|
if outPath == "" {
|
|
return
|
|
}
|
|
|
|
id := canonicalAssetIDFromName(outPath)
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
truth := finishedPhaseTruthForID(id)
|
|
|
|
rv := reflect.ValueOf(j)
|
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
|
return
|
|
}
|
|
sv := rv.Elem()
|
|
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
|
|
fv := sv.FieldByName("Meta")
|
|
if !fv.IsValid() || !fv.CanSet() {
|
|
return
|
|
}
|
|
|
|
var raw any
|
|
switch fv.Kind() {
|
|
case reflect.Interface:
|
|
if fv.IsNil() {
|
|
raw = nil
|
|
} else {
|
|
raw = fv.Interface()
|
|
}
|
|
default:
|
|
raw = fv.Interface()
|
|
}
|
|
|
|
meta := metaMapFromAny(raw)
|
|
if meta == nil {
|
|
meta = map[string]any{}
|
|
}
|
|
|
|
postworkMap, _ := meta["postwork"].(map[string]any)
|
|
if postworkMap == nil {
|
|
postworkMap = map[string]any{}
|
|
}
|
|
|
|
postworkMap["completed"] = map[string]any{
|
|
"meta": truth.MetaReady,
|
|
"teaser": truth.TeaserReady,
|
|
"thumb": truth.ThumbReady,
|
|
"sprites": truth.SpritesReady,
|
|
"analyze": truth.AnalyzeReady,
|
|
}
|
|
|
|
meta["postwork"] = postworkMap
|
|
setStructFieldJSONMap(fv, meta)
|
|
}
|
|
|
|
// ---------------- Handlers ----------------
|
|
|
|
type recordListItem struct {
|
|
ID string `json:"id"`
|
|
SourceURL string `json:"sourceUrl"`
|
|
Status string `json:"status"`
|
|
StartedAt time.Time `json:"startedAt"`
|
|
StartedAtMs int64 `json:"startedAtMs"`
|
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
|
EndedAtMs int64 `json:"endedAtMs,omitempty"`
|
|
Output string `json:"output"`
|
|
Error string `json:"error,omitempty"`
|
|
Phase string `json:"phase,omitempty"`
|
|
Progress int `json:"progress,omitempty"`
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
|
DurationSeconds float64 `json:"durationSeconds,omitempty"`
|
|
PreviewState string `json:"previewState,omitempty"`
|
|
PostWorkKey string `json:"postWorkKey,omitempty"`
|
|
PostWork *PostWorkKeyStatus `json:"postWork,omitempty"`
|
|
}
|
|
|
|
func snapshotRecordJobForList(j *RecordJob) recordListItem {
|
|
var postWorkCopy *PostWorkKeyStatus
|
|
if j.PostWork != nil {
|
|
pw := *j.PostWork
|
|
postWorkCopy = &pw
|
|
}
|
|
|
|
return recordListItem{
|
|
ID: j.ID,
|
|
SourceURL: j.SourceURL,
|
|
Status: string(j.Status),
|
|
StartedAt: j.StartedAt,
|
|
StartedAtMs: j.StartedAtMs,
|
|
EndedAt: j.EndedAt,
|
|
EndedAtMs: j.EndedAtMs,
|
|
Output: j.Output,
|
|
Error: j.Error,
|
|
Phase: j.Phase,
|
|
Progress: j.Progress,
|
|
SizeBytes: j.SizeBytes,
|
|
DurationSeconds: j.DurationSeconds,
|
|
PreviewState: j.PreviewState,
|
|
PostWorkKey: j.PostWorkKey,
|
|
PostWork: postWorkCopy,
|
|
}
|
|
}
|
|
|
|
func recordList(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
|
|
jobsMu.RLock()
|
|
list := make([]recordListItem, 0, len(jobs))
|
|
for _, j := range jobs {
|
|
if j == nil || j.Hidden {
|
|
continue
|
|
}
|
|
list = append(list, snapshotRecordJobForList(j))
|
|
}
|
|
jobsMu.RUnlock()
|
|
|
|
sort.Slice(list, func(i, j int) bool {
|
|
return list[i].StartedAt.After(list[j].StartedAt)
|
|
})
|
|
|
|
respondJSON(w, list)
|
|
}
|
|
|
|
// SSE (done stream)
|
|
|
|
func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
var req RecordRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
job, err := startRecordingInternal(req)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
respondJSON(w, job)
|
|
}
|
|
|
|
// ---- track if headers/body were already written ----
|
|
type rwTrack struct {
|
|
http.ResponseWriter
|
|
wrote bool
|
|
}
|
|
|
|
func (t *rwTrack) WriteHeader(statusCode int) {
|
|
if t.wrote {
|
|
return
|
|
}
|
|
t.wrote = true
|
|
t.ResponseWriter.WriteHeader(statusCode)
|
|
}
|
|
|
|
func (t *rwTrack) Write(p []byte) (int, error) {
|
|
if !t.wrote {
|
|
t.wrote = true
|
|
}
|
|
return t.ResponseWriter.Write(p)
|
|
}
|
|
|
|
// ensureMetaJSONForPlayback erzeugt generated/meta/<id>/meta.json falls sie fehlt.
|
|
// Best-effort: wenn es nicht geht, wird Playback nicht verhindert.
|
|
func ensureMetaJSONForPlayback(ctx context.Context, videoPath string) {
|
|
if strings.ToLower(filepath.Ext(videoPath)) != ".mp4" {
|
|
return
|
|
}
|
|
videoPath = strings.TrimSpace(videoPath)
|
|
if videoPath == "" {
|
|
return
|
|
}
|
|
|
|
fi, err := os.Stat(videoPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return
|
|
}
|
|
|
|
_, _ = ensureVideoMetaForFileBestEffort(ctx, videoPath, "")
|
|
}
|
|
|
|
func isTerminalJobStatus(status any) bool {
|
|
s := strings.TrimSpace(strings.ToLower(fmt.Sprint(status)))
|
|
switch s {
|
|
case "stopped", "finished", "failed", "done", "completed", "canceled", "cancelled":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isPostworkJob(job *RecordJob) bool {
|
|
if job == nil {
|
|
return false
|
|
}
|
|
|
|
phase := strings.TrimSpace(strings.ToLower(job.Phase))
|
|
pwKey := strings.TrimSpace(job.PostWorkKey)
|
|
|
|
// 1) expliziter Queue-Key vorhanden
|
|
if pwKey != "" {
|
|
return true
|
|
}
|
|
|
|
// 2) postWork-Status vom Refresher/Queue vorhanden
|
|
if job.PostWork != nil {
|
|
state := strings.TrimSpace(strings.ToLower(job.PostWork.State))
|
|
if state == "queued" || state == "running" {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// 3) Aufnahme ist beendet und es gibt noch eine Nachbearbeitungs-Phase
|
|
if job.EndedAt != nil && phase != "" {
|
|
return true
|
|
}
|
|
|
|
// 4) explizite postwork-Phase
|
|
if phase == "postwork" {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func getEffectivePostworkState(job *RecordJob) string {
|
|
if job == nil {
|
|
return "none"
|
|
}
|
|
|
|
phase := strings.TrimSpace(strings.ToLower(job.Phase))
|
|
pwState := ""
|
|
hasPwKey := strings.TrimSpace(job.PostWorkKey) != ""
|
|
if job.PostWork != nil {
|
|
pwState = strings.TrimSpace(strings.ToLower(job.PostWork.State))
|
|
}
|
|
|
|
if !isPostworkJob(job) {
|
|
return "none"
|
|
}
|
|
if isTerminalJobStatus(job.Status) {
|
|
return "none"
|
|
}
|
|
|
|
// 1) Harte Wahrheit aus postWork
|
|
if pwState == "queued" {
|
|
return "queued"
|
|
}
|
|
if pwState == "running" {
|
|
return "running"
|
|
}
|
|
|
|
if job.PostWork != nil {
|
|
if job.PostWork.Position > 0 {
|
|
return "queued"
|
|
}
|
|
if job.PostWork.Running > 0 && job.PostWork.Position <= 0 {
|
|
return "running"
|
|
}
|
|
}
|
|
|
|
// 2) Sobald ein PostWorkKey existiert und nichts explizit "running" sagt:
|
|
// lieber queued statt alte phase dominieren lassen
|
|
if hasPwKey {
|
|
return "queued"
|
|
}
|
|
|
|
// 3) Nur noch Heuristik für Alt-Fälle ohne PostWorkKey
|
|
switch phase {
|
|
case "postwork":
|
|
if job.Progress > 0 {
|
|
return "running"
|
|
}
|
|
return "queued"
|
|
case "remuxing", "moving", "assets", "probe":
|
|
return "running"
|
|
}
|
|
|
|
if job.EndedAt != nil {
|
|
return "queued"
|
|
}
|
|
|
|
return "none"
|
|
}
|
|
|
|
func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
|
if id == "" {
|
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jobsMu.RLock()
|
|
job, ok := jobs[id]
|
|
jobsMu.RUnlock()
|
|
if !ok || job == nil {
|
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if !isPostworkJob(job) {
|
|
http.Error(w, "job ist kein postwork-job", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
state := getEffectivePostworkState(job)
|
|
if state != "queued" {
|
|
http.Error(w, "nur queued postwork-jobs können entfernt werden", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
postKey := strings.TrimSpace(job.PostWorkKey)
|
|
if postKey == "" {
|
|
http.Error(w, "postwork key fehlt", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
if ok := postWorkQ.RemoveQueued(postKey); !ok {
|
|
http.Error(w, "postwork job konnte nicht aus der warteschlange entfernt werden", http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
out := strings.TrimSpace(job.Output)
|
|
if out != "" {
|
|
_ = removeWithRetry(out)
|
|
purgeDurationCacheForPath(out)
|
|
|
|
id1 := canonicalAssetIDFromName(out)
|
|
if strings.TrimSpace(id1) != "" {
|
|
removeGeneratedForID(id1)
|
|
}
|
|
}
|
|
|
|
jobsMu.Lock()
|
|
delete(jobs, job.ID)
|
|
jobsMu.Unlock()
|
|
|
|
publishJobRemove(job)
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"id": id,
|
|
})
|
|
}
|
|
|
|
func recordVideo(w http.ResponseWriter, r *http.Request) {
|
|
tw := &rwTrack{ResponseWriter: w}
|
|
w = tw
|
|
|
|
writeErr := func(code int, msg string) {
|
|
if tw.wrote {
|
|
appLogln("[recordVideo] late error (headers already sent):", code, msg)
|
|
return
|
|
}
|
|
http.Error(w, msg, code)
|
|
}
|
|
writeStatus := func(code int) {
|
|
if tw.wrote {
|
|
return
|
|
}
|
|
w.WriteHeader(code)
|
|
}
|
|
|
|
// ---- CORS ----
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Range, If-Range, If-Modified-Since, If-None-Match")
|
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
if r.Method == http.MethodOptions {
|
|
writeStatus(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
outPath, ok, code, msg := resolvePlayablePathFromQuery(r)
|
|
if !ok {
|
|
writeErr(code, msg)
|
|
return
|
|
}
|
|
|
|
// ---- TS -> MP4 (on-demand remux) ----
|
|
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
|
|
writeErr(http.StatusConflict, "TS-Datei ist noch nicht nachbearbeitet und kann nicht direkt abgespielt werden")
|
|
return
|
|
}
|
|
|
|
// ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern
|
|
if strings.ToLower(filepath.Ext(outPath)) == ".mp4" {
|
|
kind, _ := sniffVideoKind(outPath)
|
|
switch kind {
|
|
case "ts":
|
|
writeErr(http.StatusConflict, "Datei hat .mp4-Endung, enthält aber TS-Daten und wird nicht on-demand remuxt")
|
|
return
|
|
case "html":
|
|
writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)")
|
|
return
|
|
}
|
|
}
|
|
|
|
ensureMetaJSONForPlayback(r.Context(), outPath)
|
|
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
serveVideoFile(w, r, outPath)
|
|
}
|
|
|
|
func recordStatus(w http.ResponseWriter, r *http.Request) {
|
|
id := q(r, "id")
|
|
if id == "" {
|
|
http.Error(w, "id fehlt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jobsMu.RLock()
|
|
job, ok := jobs[id]
|
|
if !ok || job == nil {
|
|
jobsMu.RUnlock()
|
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
c := *job
|
|
if job.PostWork != nil {
|
|
pw := *job.PostWork
|
|
c.PostWork = &pw
|
|
}
|
|
jobsMu.RUnlock()
|
|
|
|
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
|
applyFinishedPhaseTruthToRecordJobMeta(&c)
|
|
respondJSON(w, &c)
|
|
}
|
|
|
|
func recordStop(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
id := q(r, "id")
|
|
|
|
jobsMu.RLock()
|
|
job, ok := jobs[id]
|
|
jobsMu.RUnlock()
|
|
if !ok {
|
|
http.Error(w, "job nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
stopJobsInternal([]*RecordJob{job})
|
|
respondJSON(w, job)
|
|
}
|
|
|
|
func recordStopAll(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
type stopAllResp struct {
|
|
OK bool `json:"ok"`
|
|
Stopped int `json:"stopped"`
|
|
IDs []string `json:"ids,omitempty"`
|
|
}
|
|
|
|
jobsMu.RLock()
|
|
toStop := make([]*RecordJob, 0, len(jobs))
|
|
ids := make([]string, 0, len(jobs))
|
|
|
|
for _, job := range jobs {
|
|
if job == nil {
|
|
continue
|
|
}
|
|
|
|
// nur aktive echte Downloads stoppen
|
|
if isPostworkJob(job) {
|
|
continue
|
|
}
|
|
if isTerminalJobStatus(job.Status) {
|
|
continue
|
|
}
|
|
if job.EndedAt != nil {
|
|
continue
|
|
}
|
|
|
|
toStop = append(toStop, job)
|
|
ids = append(ids, job.ID)
|
|
}
|
|
jobsMu.RUnlock()
|
|
|
|
if len(toStop) > 0 {
|
|
stopJobsInternal(toStop)
|
|
}
|
|
|
|
respondJSON(w, stopAllResp{
|
|
OK: true,
|
|
Stopped: len(toStop),
|
|
IDs: ids,
|
|
})
|
|
}
|
|
|
|
// ---------------- Done index cache ----------------
|
|
|
|
type doneIndexItem struct {
|
|
job *RecordJob
|
|
endedAt time.Time
|
|
fileSort string
|
|
fromKeep bool
|
|
modelKey string // lower
|
|
}
|
|
|
|
type doneIndexCache struct {
|
|
mu sync.Mutex
|
|
builtAt time.Time
|
|
seq uint64
|
|
doneAbs string
|
|
|
|
items []doneIndexItem
|
|
sortedIdx map[string][]int // key: "<includeKeep 0/1>|<sortMode>"
|
|
}
|
|
|
|
var doneCache doneIndexCache
|
|
|
|
func normalizeQueryModel(raw string) string {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
s = strings.TrimPrefix(s, "http://")
|
|
s = strings.TrimPrefix(s, "https://")
|
|
|
|
if strings.Contains(s, "/") {
|
|
parts := strings.Split(s, "/")
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
p := strings.TrimSpace(parts[i])
|
|
if p != "" {
|
|
s = p
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(s, ":") {
|
|
parts := strings.Split(s, ":")
|
|
s = strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
|
|
s = strings.TrimPrefix(s, "@")
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
func isTemporaryDoneVideoName(name string) bool {
|
|
n := strings.ToLower(strings.TrimSpace(filepath.Base(name)))
|
|
if n == "" {
|
|
return false
|
|
}
|
|
|
|
// versteckte / temporäre Dateien wie:
|
|
// ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000.mp4"
|
|
if strings.HasPrefix(n, ".") {
|
|
return true
|
|
}
|
|
|
|
// typische temporäre Marker
|
|
if strings.Contains(n, ".tmp.") ||
|
|
strings.HasSuffix(n, ".tmp.mp4") ||
|
|
strings.HasSuffix(n, ".tmp.ts") ||
|
|
strings.Contains(n, ".partial.") ||
|
|
strings.Contains(n, ".part.") {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep)
|
|
func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) {
|
|
items := make([]doneIndexItem, 0, 2048)
|
|
sortedIdx := make(map[string][]int)
|
|
|
|
isTrashPathLocal := func(full string) bool {
|
|
p := strings.ToLower(filepath.ToSlash(strings.TrimSpace(full)))
|
|
return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash")
|
|
}
|
|
|
|
addFile := func(full string, fi os.FileInfo) {
|
|
if fi == nil || fi.IsDir() || fi.Size() == 0 {
|
|
return
|
|
}
|
|
if isTrashPathLocal(full) {
|
|
return
|
|
}
|
|
|
|
name := filepath.Base(full)
|
|
if isTemporaryDoneVideoName(name) {
|
|
return
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if ext != ".mp4" && ext != ".ts" {
|
|
return
|
|
}
|
|
|
|
// keep?
|
|
p := strings.ToLower(filepath.ToSlash(full))
|
|
fromKeep := strings.Contains(p, "/keep/")
|
|
|
|
// started/ended
|
|
t := fi.ModTime()
|
|
start := t
|
|
|
|
base := strings.TrimSuffix(name, filepath.Ext(name))
|
|
|
|
// WICHTIG:
|
|
// stabile ID ohne HOT-Prefix, damit Rename HOT <-> non-HOT
|
|
// im Frontend nicht zu einem neuen React-Key führt.
|
|
stableID := stripHotPrefix(base)
|
|
if strings.TrimSpace(stableID) == "" {
|
|
stableID = base
|
|
}
|
|
|
|
// Timestamp immer auf Basis des HOT-bereinigten Namens parsen
|
|
stem := stableID
|
|
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
|
|
mm, _ := strconv.Atoi(m[2])
|
|
dd, _ := strconv.Atoi(m[3])
|
|
yy, _ := strconv.Atoi(m[4])
|
|
hh, _ := strconv.Atoi(m[5])
|
|
mi, _ := strconv.Atoi(m[6])
|
|
ss, _ := strconv.Atoi(m[7])
|
|
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
|
|
}
|
|
|
|
// modelKey (lower)
|
|
mk := strings.ToLower(strings.TrimSpace(modelKeyFromFilenameOrPath(name, full, doneAbs)))
|
|
if mk == "" {
|
|
parent := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(full))))
|
|
if parent != "" && parent != "keep" {
|
|
mk = parent
|
|
}
|
|
}
|
|
|
|
// fileSort (hot-prefix raus)
|
|
fs := strings.ToLower(name)
|
|
fs = strings.TrimPrefix(fs, "hot ")
|
|
|
|
// duration + srcURL
|
|
dur := 0.0
|
|
srcURL := ""
|
|
|
|
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(full), filepath.Ext(full)))
|
|
if strings.TrimSpace(id) != "" {
|
|
if mp, err := generatedMetaFile(id); err == nil {
|
|
if d, ok := readVideoMetaDuration(mp, fi); ok {
|
|
dur = d
|
|
}
|
|
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
|
|
srcURL = u
|
|
}
|
|
}
|
|
}
|
|
if dur <= 0 {
|
|
dur = durationSecondsCacheOnly(full, fi)
|
|
}
|
|
|
|
ended := t
|
|
items = append(items, doneIndexItem{
|
|
job: &RecordJob{
|
|
ID: stableID,
|
|
Output: full,
|
|
SourceURL: srcURL,
|
|
Status: JobFinished,
|
|
StartedAt: start,
|
|
EndedAt: &ended,
|
|
DurationSeconds: dur,
|
|
SizeBytes: fi.Size(),
|
|
},
|
|
endedAt: ended,
|
|
fileSort: fs,
|
|
fromKeep: fromKeep,
|
|
modelKey: mk,
|
|
})
|
|
}
|
|
|
|
scanDir := func(dir string, skipKeep bool) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
if strings.EqualFold(e.Name(), ".trash") || strings.EqualFold(e.Name(), ".postwork_tmp") {
|
|
continue
|
|
}
|
|
if skipKeep && e.Name() == "keep" {
|
|
continue
|
|
}
|
|
|
|
sub := filepath.Join(dir, e.Name())
|
|
subs, err := os.ReadDir(sub)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, se := range subs {
|
|
if se.IsDir() {
|
|
continue
|
|
}
|
|
full := filepath.Join(sub, se.Name())
|
|
fi, err := os.Stat(full)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
addFile(full, fi)
|
|
}
|
|
continue
|
|
}
|
|
|
|
full := filepath.Join(dir, e.Name())
|
|
fi, err := os.Stat(full)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
addFile(full, fi)
|
|
}
|
|
}
|
|
|
|
// done (ohne keep)
|
|
scanDir(doneAbs, true)
|
|
|
|
// keep
|
|
scanDir(filepath.Join(doneAbs, "keep"), false)
|
|
|
|
mkSorted := func(includeKeep bool, sortMode string) []int {
|
|
idx := make([]int, 0, len(items))
|
|
for i := range items {
|
|
if !includeKeep && items[i].fromKeep {
|
|
continue
|
|
}
|
|
idx = append(idx, i)
|
|
}
|
|
|
|
durationForSort := func(it doneIndexItem) (float64, bool) {
|
|
if it.job.DurationSeconds > 0 {
|
|
return it.job.DurationSeconds, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
sort.Slice(idx, func(a, b int) bool {
|
|
A := items[idx[a]]
|
|
B := items[idx[b]]
|
|
|
|
ta, tb := A.endedAt, B.endedAt
|
|
|
|
switch sortMode {
|
|
case "completed_asc":
|
|
if !ta.Equal(tb) {
|
|
return ta.Before(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "completed_desc":
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "file_asc":
|
|
if A.fileSort != B.fileSort {
|
|
return A.fileSort < B.fileSort
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "file_desc":
|
|
if A.fileSort != B.fileSort {
|
|
return A.fileSort > B.fileSort
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "duration_asc":
|
|
da, okA := durationForSort(A)
|
|
db, okB := durationForSort(B)
|
|
if okA != okB {
|
|
return okA
|
|
}
|
|
if okA && okB && da != db {
|
|
return da < db
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "duration_desc":
|
|
da, okA := durationForSort(A)
|
|
db, okB := durationForSort(B)
|
|
if okA != okB {
|
|
return okA
|
|
}
|
|
if okA && okB && da != db {
|
|
return da > db
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "size_asc":
|
|
if A.job.SizeBytes != B.job.SizeBytes {
|
|
return A.job.SizeBytes < B.job.SizeBytes
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
case "size_desc":
|
|
if A.job.SizeBytes != B.job.SizeBytes {
|
|
return A.job.SizeBytes > B.job.SizeBytes
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
|
|
default:
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return A.fileSort < B.fileSort
|
|
}
|
|
})
|
|
|
|
return idx
|
|
}
|
|
|
|
modes := []string{
|
|
"completed_desc", "completed_asc",
|
|
"file_asc", "file_desc",
|
|
"duration_asc", "duration_desc",
|
|
"size_asc", "size_desc",
|
|
}
|
|
|
|
for _, m := range modes {
|
|
sortedIdx["0|"+m] = mkSorted(false, m)
|
|
sortedIdx["1|"+m] = mkSorted(true, m)
|
|
}
|
|
|
|
return items, sortedIdx
|
|
}
|
|
|
|
func applyGeneratedMetaToRecordJobMeta(j *RecordJob) {
|
|
if j == nil {
|
|
return
|
|
}
|
|
|
|
outPath := strings.TrimSpace(j.Output)
|
|
if outPath == "" {
|
|
return
|
|
}
|
|
|
|
fi, err := os.Stat(outPath)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 {
|
|
return
|
|
}
|
|
|
|
id := canonicalAssetIDFromName(outPath)
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
metaPath, err := generatedMetaFile(id)
|
|
if err != nil || strings.TrimSpace(metaPath) == "" {
|
|
return
|
|
}
|
|
|
|
meta, ok := readVideoMetaIfValid(metaPath, fi)
|
|
if !ok || meta == nil {
|
|
return
|
|
}
|
|
|
|
rv := reflect.ValueOf(j)
|
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
|
return
|
|
}
|
|
|
|
sv := rv.Elem()
|
|
if !sv.IsValid() || sv.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
|
|
fv := sv.FieldByName("Meta")
|
|
if !fv.IsValid() || !fv.CanSet() {
|
|
return
|
|
}
|
|
|
|
setStructFieldJSONMap(fv, metaMapFromAny(meta))
|
|
}
|
|
|
|
// ---------------- Done meta + list ----------------
|
|
|
|
func activePostworkBasenameSet() map[string]struct{} {
|
|
out := make(map[string]struct{})
|
|
|
|
jobsMu.RLock()
|
|
defer jobsMu.RUnlock()
|
|
|
|
for _, j := range jobs {
|
|
if j == nil {
|
|
continue
|
|
}
|
|
if !isPostworkJob(j) {
|
|
continue
|
|
}
|
|
if isTerminalJobStatus(j.Status) {
|
|
continue
|
|
}
|
|
|
|
base := strings.TrimSpace(filepath.Base(j.Output))
|
|
if base == "" {
|
|
continue
|
|
}
|
|
out[strings.ToLower(base)] = struct{}{}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
|
|
// File-Mode: /api/record/done/meta?file=XYZ.mp4
|
|
if file, ok, err := safeBasenameQuery(r, "file"); err != nil {
|
|
http.Error(w, "ungültiger file", http.StatusBadRequest)
|
|
return
|
|
} else if ok {
|
|
if !isAllowedVideoExt(file) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
full, _, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 {
|
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
outPath := filepath.Clean(strings.TrimSpace(full))
|
|
|
|
ensureMetaJSONForPlayback(r.Context(), outPath)
|
|
|
|
resp := doneMetaFileResp{File: filepath.Base(outPath)}
|
|
|
|
id := canonicalAssetIDFromName(outPath)
|
|
|
|
if strings.TrimSpace(id) != "" {
|
|
if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" {
|
|
if mfi, serr := os.Stat(mp); serr == nil && mfi != nil && !mfi.IsDir() && mfi.Size() > 0 {
|
|
if meta, ok := readVideoMetaIfValid(mp, fi); ok && meta != nil {
|
|
resp.MetaExists = true
|
|
resp.Meta = meta
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sprite-Wahrheit aus Dateisystem/meta ggf. ergänzen/überschreiben
|
|
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
|
|
|
|
if resp.Meta == nil {
|
|
resp.Meta = &videoMeta{}
|
|
}
|
|
|
|
// Fallback Duration, falls Meta fehlt oder unvollständig
|
|
if resp.Meta.Media.DurationSeconds <= 0 {
|
|
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
|
|
resp.Meta.Media.DurationSeconds = d
|
|
}
|
|
}
|
|
|
|
respondJSON(w, resp)
|
|
return
|
|
}
|
|
|
|
// Count-Mode
|
|
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
|
|
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
|
|
qModel := normalizeQueryModel(r.URL.Query().Get("model"))
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
curSeq := atomic.LoadUint64(&doneSeq)
|
|
now := time.Now()
|
|
|
|
doneCache.mu.Lock()
|
|
needRebuild := doneCache.seq != curSeq ||
|
|
doneCache.doneAbs != doneAbs ||
|
|
now.Sub(doneCache.builtAt) > 30*time.Second
|
|
|
|
if needRebuild {
|
|
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
|
|
doneCache.items = nil
|
|
doneCache.sortedIdx = make(map[string][]int, 16)
|
|
modes := []string{
|
|
"completed_desc", "completed_asc",
|
|
"file_asc", "file_desc",
|
|
"duration_asc", "duration_desc",
|
|
"size_asc", "size_desc",
|
|
}
|
|
for _, m := range modes {
|
|
doneCache.sortedIdx["0|"+m] = []int{}
|
|
doneCache.sortedIdx["1|"+m] = []int{}
|
|
}
|
|
doneCache.seq = curSeq
|
|
doneCache.doneAbs = doneAbs
|
|
doneCache.builtAt = now
|
|
} else {
|
|
items, sorted := buildDoneIndex(doneAbs)
|
|
doneCache.items = items
|
|
doneCache.sortedIdx = sorted
|
|
doneCache.seq = curSeq
|
|
doneCache.doneAbs = doneAbs
|
|
doneCache.builtAt = now
|
|
}
|
|
}
|
|
|
|
items := doneCache.items
|
|
sortedAll := doneCache.sortedIdx
|
|
doneCache.mu.Unlock()
|
|
|
|
activePostwork := activePostworkBasenameSet()
|
|
|
|
count := 0
|
|
if qModel == "" {
|
|
incKey := "0"
|
|
if includeKeep {
|
|
incKey = "1"
|
|
}
|
|
|
|
for _, idx := range sortedAll[incKey+"|completed_desc"] {
|
|
it := items[idx]
|
|
if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
} else {
|
|
for _, it := range items {
|
|
if !includeKeep && it.fromKeep {
|
|
continue
|
|
}
|
|
if it.modelKey != qModel {
|
|
continue
|
|
}
|
|
if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
}
|
|
|
|
respondJSON(w, doneMetaResp{Count: count})
|
|
}
|
|
|
|
func recordDoneList(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodGet) {
|
|
return
|
|
}
|
|
|
|
qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep")))
|
|
includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes"
|
|
qModel := normalizeQueryModel(r.URL.Query().Get("model"))
|
|
|
|
page := 0
|
|
pageSize := 0
|
|
if v := strings.TrimSpace(r.URL.Query().Get("page")); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
page = n
|
|
}
|
|
}
|
|
if v := strings.TrimSpace(r.URL.Query().Get("pageSize")); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
pageSize = n
|
|
}
|
|
}
|
|
|
|
sortMode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
|
|
if sortMode == "" {
|
|
sortMode = "completed_desc"
|
|
}
|
|
if sortMode == "model_asc" {
|
|
sortMode = "file_asc"
|
|
}
|
|
if sortMode == "model_desc" {
|
|
sortMode = "file_desc"
|
|
}
|
|
|
|
qAll := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("all")))
|
|
fetchAll := qAll == "1" || qAll == "true" || qAll == "yes"
|
|
if fetchAll {
|
|
page = 0
|
|
pageSize = 0
|
|
}
|
|
|
|
qWithCount := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("withCount")))
|
|
withCount := qWithCount == "1" || qWithCount == "true" || qWithCount == "yes"
|
|
|
|
compareIdx := func(items []doneIndexItem, sortMode string, ia, ib int) bool {
|
|
a := items[ia]
|
|
b := items[ib]
|
|
ta, tb := a.endedAt, b.endedAt
|
|
|
|
durationForSort := func(j *RecordJob) (sec float64, ok bool) {
|
|
if j.DurationSeconds > 0 {
|
|
return j.DurationSeconds, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
switch sortMode {
|
|
case "completed_asc":
|
|
if !ta.Equal(tb) {
|
|
return ta.Before(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "completed_desc":
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "file_asc":
|
|
if a.fileSort != b.fileSort {
|
|
return a.fileSort < b.fileSort
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "file_desc":
|
|
if a.fileSort != b.fileSort {
|
|
return a.fileSort > b.fileSort
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "duration_asc":
|
|
da, okA := durationForSort(a.job)
|
|
db, okB := durationForSort(b.job)
|
|
if okA != okB {
|
|
return okA
|
|
}
|
|
if okA && okB && da != db {
|
|
return da < db
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "duration_desc":
|
|
da, okA := durationForSort(a.job)
|
|
db, okB := durationForSort(b.job)
|
|
if okA != okB {
|
|
return okA
|
|
}
|
|
if okA && okB && da != db {
|
|
return da > db
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "size_asc":
|
|
if a.job.SizeBytes != b.job.SizeBytes {
|
|
return a.job.SizeBytes < b.job.SizeBytes
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
case "size_desc":
|
|
if a.job.SizeBytes != b.job.SizeBytes {
|
|
return a.job.SizeBytes > b.job.SizeBytes
|
|
}
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
default:
|
|
if !ta.Equal(tb) {
|
|
return ta.After(tb)
|
|
}
|
|
return a.fileSort < b.fileSort
|
|
}
|
|
}
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
respondJSON(w, doneListResponse{Items: []*RecordJob{}, TotalCount: 0, Page: page, PageSize: pageSize})
|
|
return
|
|
}
|
|
|
|
curSeq := atomic.LoadUint64(&doneSeq)
|
|
now := time.Now()
|
|
|
|
doneCache.mu.Lock()
|
|
needRebuild := doneCache.seq != curSeq ||
|
|
doneCache.doneAbs != doneAbs ||
|
|
now.Sub(doneCache.builtAt) > 30*time.Second
|
|
|
|
if needRebuild {
|
|
if _, err := os.Stat(doneAbs); err != nil && os.IsNotExist(err) {
|
|
doneCache.items = nil
|
|
doneCache.sortedIdx = make(map[string][]int, 16)
|
|
modes := []string{
|
|
"completed_desc", "completed_asc",
|
|
"file_asc", "file_desc",
|
|
"duration_asc", "duration_desc",
|
|
"size_asc", "size_desc",
|
|
}
|
|
for _, m := range modes {
|
|
doneCache.sortedIdx["0|"+m] = []int{}
|
|
doneCache.sortedIdx["1|"+m] = []int{}
|
|
}
|
|
doneCache.seq = curSeq
|
|
doneCache.doneAbs = doneAbs
|
|
doneCache.builtAt = now
|
|
} else {
|
|
items, sorted := buildDoneIndex(doneAbs)
|
|
doneCache.items = items
|
|
doneCache.sortedIdx = sorted
|
|
doneCache.seq = curSeq
|
|
doneCache.doneAbs = doneAbs
|
|
doneCache.builtAt = now
|
|
}
|
|
}
|
|
|
|
items := doneCache.items
|
|
sortedAll := doneCache.sortedIdx
|
|
doneCache.mu.Unlock()
|
|
|
|
incKey := "0"
|
|
if includeKeep {
|
|
incKey = "1"
|
|
}
|
|
|
|
var idx []int
|
|
if qModel == "" {
|
|
idx = sortedAll[incKey+"|"+sortMode]
|
|
if idx == nil {
|
|
idx = sortedAll[incKey+"|completed_desc"]
|
|
if idx == nil {
|
|
idx = make([]int, 0)
|
|
}
|
|
}
|
|
} else {
|
|
idx = make([]int, 0, 256)
|
|
for i := range items {
|
|
if !includeKeep && items[i].fromKeep {
|
|
continue
|
|
}
|
|
if items[i].modelKey == qModel {
|
|
idx = append(idx, i)
|
|
}
|
|
}
|
|
sort.Slice(idx, func(a, b int) bool {
|
|
return compareIdx(items, sortMode, idx[a], idx[b])
|
|
})
|
|
}
|
|
|
|
activePostwork := activePostworkBasenameSet()
|
|
|
|
filteredIdx := make([]int, 0, len(idx))
|
|
for _, ii := range idx {
|
|
it := items[ii]
|
|
if it.job == nil {
|
|
continue
|
|
}
|
|
if _, exists := activePostwork[strings.ToLower(filepath.Base(it.job.Output))]; exists {
|
|
continue
|
|
}
|
|
filteredIdx = append(filteredIdx, ii)
|
|
}
|
|
|
|
totalCount := len(filteredIdx)
|
|
|
|
start := 0
|
|
end := totalCount
|
|
if pageSize > 0 && !fetchAll {
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
start = (page - 1) * pageSize
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start >= totalCount {
|
|
start = totalCount
|
|
}
|
|
end = start + pageSize
|
|
if end > totalCount {
|
|
end = totalCount
|
|
}
|
|
}
|
|
|
|
out := make([]*RecordJob, 0, max(0, end-start))
|
|
|
|
for _, ii := range filteredIdx[start:end] {
|
|
base := items[ii].job
|
|
if base == nil {
|
|
continue
|
|
}
|
|
|
|
c := *base
|
|
|
|
// vollständiges generated meta.json laden (inkl. analysis.highlights.rating)
|
|
applyGeneratedMetaToRecordJobMeta(&c)
|
|
|
|
// danach gezielte Wahrheiten/Fallbacks drüberlegen
|
|
applyPreviewSpriteTruthToRecordJobMeta(&c)
|
|
applyFinishedPhaseTruthToRecordJobMeta(&c)
|
|
|
|
out = append(out, &c)
|
|
}
|
|
|
|
if withCount {
|
|
respondJSON(w, map[string]any{"count": totalCount, "items": out})
|
|
return
|
|
}
|
|
|
|
respondJSON(w, doneListResponse{Items: out, TotalCount: totalCount, Page: page, PageSize: pageSize})
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ---------------- File operations (delete/undo/keep/hot) ----------------
|
|
|
|
func renameWithRetryAggressive(src, dst string) error {
|
|
var lastErr error
|
|
delays := []time.Duration{
|
|
80 * time.Millisecond,
|
|
140 * time.Millisecond,
|
|
220 * time.Millisecond,
|
|
320 * time.Millisecond,
|
|
450 * time.Millisecond,
|
|
650 * time.Millisecond,
|
|
}
|
|
|
|
for i, d := range delays {
|
|
if err := os.Rename(src, dst); err == nil {
|
|
return nil
|
|
} else {
|
|
lastErr = err
|
|
if runtime.GOOS != "windows" || !isSharingViolation(err) {
|
|
return err
|
|
}
|
|
}
|
|
if i < len(delays)-1 {
|
|
time.Sleep(d)
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
func cancelDeferredEnrichForFile(file string) {
|
|
file = strings.TrimSpace(file)
|
|
if file == "" {
|
|
return
|
|
}
|
|
|
|
id := canonicalAssetIDFromName(file)
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
|
|
enrichKey := "enrich:" + id
|
|
st := enrichQ.StatusForKey(enrichKey)
|
|
|
|
switch st.State {
|
|
case "queued":
|
|
if enrichQ.RemoveQueued(enrichKey) {
|
|
// optional: stale badge sofort entfernen
|
|
ev := finishedPostworkEvent{
|
|
Type: "finished_postwork",
|
|
File: file,
|
|
AssetID: id,
|
|
Queue: "enrich",
|
|
State: "missing",
|
|
Phase: "assets",
|
|
Label: "",
|
|
TS: time.Now().UnixMilli(),
|
|
}
|
|
if b, err := json.Marshal(ev); err == nil {
|
|
publishSSE("finishedPostwork", b)
|
|
}
|
|
}
|
|
|
|
case "running":
|
|
if enrichQ.Cancel(enrichKey) {
|
|
// ffmpeg / reads / handles kurz auslaufen lassen
|
|
time.Sleep(350 * time.Millisecond)
|
|
|
|
ev := finishedPostworkEvent{
|
|
Type: "finished_postwork",
|
|
File: file,
|
|
AssetID: id,
|
|
Queue: "enrich",
|
|
State: "missing",
|
|
Phase: "assets",
|
|
Label: "",
|
|
TS: time.Now().UnixMilli(),
|
|
}
|
|
if b, err := json.Marshal(ev); err == nil {
|
|
publishSSE("finishedPostwork", b)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func releaseFileForMutation(file string) {
|
|
file = strings.TrimSpace(file)
|
|
if file == "" {
|
|
return
|
|
}
|
|
|
|
// Enrich für genau diese Datei stoppen / aus Queue nehmen
|
|
cancelDeferredEnrichForFile(file)
|
|
|
|
// releaseFileTasks(file) macht jetzt das gezielte Freigeben/Abbrechen
|
|
// für genau diese Datei, inkl. generate-assets Skip pro Datei.
|
|
resp := releaseFileTasks(file)
|
|
|
|
// Kurz warten, damit Handles der betroffenen Datei schließen
|
|
if resp.ReleasedAny {
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) {
|
|
trashDir = filepath.Clean(strings.TrimSpace(trashDir))
|
|
keepToken = strings.TrimSpace(keepToken)
|
|
keepTrashName = strings.TrimSpace(keepTrashName)
|
|
|
|
if trashDir == "" || keepToken == "" || keepTrashName == "" {
|
|
return
|
|
}
|
|
|
|
keepMetaName := keepToken + ".json"
|
|
|
|
entries, err := os.ReadDir(trashDir)
|
|
if err != nil {
|
|
appLogln("⚠️ trash cleanup read failed:", err)
|
|
return
|
|
}
|
|
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
|
|
// Das zuletzt gelöschte Video behalten
|
|
if name == keepTrashName {
|
|
continue
|
|
}
|
|
|
|
// Token-Meta für Undo behalten
|
|
if name == keepMetaName {
|
|
continue
|
|
}
|
|
|
|
// last.json behalten, damit Debug/Komfort weiterhin funktioniert
|
|
if name == "last.json" {
|
|
continue
|
|
}
|
|
|
|
full := filepath.Join(trashDir, name)
|
|
|
|
if e.IsDir() {
|
|
if err := os.RemoveAll(full); err != nil {
|
|
appLogln("⚠️ trash cleanup dir failed:", full, err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if err := removeWithRetry(full); err != nil {
|
|
appLogln("⚠️ trash cleanup file failed:", full, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
|
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
file, ok, err := safeBasenameQuery(r, "file")
|
|
if err != nil || !ok {
|
|
http.Error(w, "file fehlt/ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isAllowedVideoExt(file) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
releaseFileForMutation(file)
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
target, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil {
|
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if fi != nil && fi.IsDir() {
|
|
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
trashDir := filepath.Join(doneAbs, ".trash")
|
|
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
|
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
origDir := filepath.Dir(target)
|
|
relDir, err := filepath.Rel(doneAbs, origDir)
|
|
if err != nil {
|
|
http.Error(w, "rel dir berechnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
relDir = filepath.ToSlash(relDir)
|
|
if strings.TrimSpace(relDir) == "" {
|
|
relDir = "."
|
|
}
|
|
|
|
tok, err := encodeUndoDeleteToken(undoDeleteToken{
|
|
Trash: "",
|
|
RelDir: relDir,
|
|
File: file,
|
|
From: from,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Token ist RawURLEncoding-safe. Prefix macht Restore ohne last.json eindeutig.
|
|
trashName := tok + "__" + file
|
|
trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_")
|
|
dst := filepath.Join(trashDir, trashName)
|
|
|
|
if err := renameWithRetryAggressive(target, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "trash move fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
meta := trashMeta{
|
|
Token: tok,
|
|
TrashName: trashName,
|
|
RelDir: relDir,
|
|
File: file,
|
|
From: from,
|
|
DeletedAt: time.Now().Unix(),
|
|
}
|
|
|
|
if b, err := json.Marshal(meta); err == nil {
|
|
// Token-spezifische Meta für das aktuelle Undo.
|
|
_ = os.WriteFile(filepath.Join(trashDir, tok+".json"), b, 0o644)
|
|
|
|
// Komfort/Debug.
|
|
_ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644)
|
|
|
|
// .trash sauber halten:
|
|
// Nur das zuletzt gelöschte Video + dessen Meta + last.json behalten.
|
|
func() {
|
|
trashCleanupMu.Lock()
|
|
defer trashCleanupMu.Unlock()
|
|
|
|
cleanupTrashKeepOnlyLatest(trashDir, tok, trashName)
|
|
}()
|
|
}
|
|
|
|
purgeDurationCacheForPath(target)
|
|
removeJobsByOutputBasename(file)
|
|
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"file": file,
|
|
"from": from,
|
|
"undoToken": tok,
|
|
"trashed": true,
|
|
})
|
|
}
|
|
|
|
func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
raw := strings.TrimSpace(r.URL.Query().Get("token"))
|
|
if raw == "" {
|
|
http.Error(w, "token fehlt", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tok, err := decodeUndoDeleteToken(raw)
|
|
if err != nil {
|
|
http.Error(w, "token ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
trashDir := filepath.Join(doneAbs, ".trash")
|
|
|
|
var meta trashMeta
|
|
|
|
// 1) Neue Variante: token-spezifische Meta.
|
|
metaPath := filepath.Join(trashDir, raw+".json")
|
|
if b, err := os.ReadFile(metaPath); err == nil && len(b) > 0 {
|
|
if err := json.Unmarshal(b, &meta); err != nil {
|
|
http.Error(w, "trash meta ungültig", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
} else {
|
|
// 2) Fallback für alte/halb geschriebene Einträge:
|
|
// Datei anhand Token-Prefix suchen.
|
|
entries, rerr := os.ReadDir(trashDir)
|
|
if rerr != nil {
|
|
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
prefix := raw + "__"
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
|
|
meta = trashMeta{
|
|
Token: raw,
|
|
TrashName: name,
|
|
RelDir: tok.RelDir,
|
|
File: tok.File,
|
|
From: tok.From,
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if strings.TrimSpace(meta.Token) == "" ||
|
|
strings.TrimSpace(meta.TrashName) == "" ||
|
|
strings.TrimSpace(meta.File) == "" {
|
|
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if strings.TrimSpace(meta.From) == "" {
|
|
relLower := strings.ToLower(filepath.ToSlash(strings.TrimSpace(meta.RelDir)))
|
|
if relLower == "keep" || strings.HasPrefix(relLower, "keep/") {
|
|
meta.From = "keep"
|
|
} else {
|
|
meta.From = "done"
|
|
}
|
|
}
|
|
|
|
if meta.Token != raw {
|
|
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if tok.File != meta.File || tok.RelDir != meta.RelDir {
|
|
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if !isSafeBasename(meta.TrashName) || !isSafeBasename(meta.File) || !isSafeRelDir(meta.RelDir) {
|
|
http.Error(w, "token inhalt ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if !isAllowedVideoExt(meta.File) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
src := filepath.Join(trashDir, meta.TrashName)
|
|
|
|
rel := meta.RelDir
|
|
if rel == "." {
|
|
rel = ""
|
|
}
|
|
|
|
dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel))
|
|
dstDirClean := filepath.Clean(dstDir)
|
|
doneClean := filepath.Clean(doneAbs)
|
|
|
|
if !strings.HasPrefix(strings.ToLower(dstDirClean)+string(os.PathSeparator), strings.ToLower(doneClean)+string(os.PathSeparator)) &&
|
|
!strings.EqualFold(dstDirClean, doneClean) {
|
|
http.Error(w, "zielpfad ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := os.MkdirAll(dstDirClean, 0o755); err != nil {
|
|
http.Error(w, "zielordner erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
dst, err := uniqueDestPath(dstDirClean, meta.File)
|
|
if err != nil {
|
|
http.Error(w, "zielname nicht verfügbar: "+err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
http.Error(w, "restore fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "restore fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
_ = os.Chtimes(dst, now, now)
|
|
|
|
_ = os.Remove(metaPath)
|
|
|
|
// last.json nur löschen, wenn es zu genau diesem Token gehört.
|
|
if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 {
|
|
var last trashMeta
|
|
if json.Unmarshal(b, &last) == nil && last.Token == raw {
|
|
_ = os.Remove(filepath.Join(trashDir, "last.json"))
|
|
}
|
|
}
|
|
|
|
purgeDurationCacheForPath(src)
|
|
purgeDurationCacheForPath(dst)
|
|
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"file": meta.File,
|
|
"restoredFile": filepath.Base(dst),
|
|
"from": meta.From,
|
|
})
|
|
}
|
|
|
|
func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
file, ok, err := safeBasenameQuery(r, "file")
|
|
if err != nil || !ok {
|
|
http.Error(w, "file fehlt/ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isAllowedVideoExt(file) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
releaseFileForMutation(file)
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil {
|
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if from != "keep" {
|
|
http.Error(w, "datei ist nicht in keep", http.StatusConflict)
|
|
return
|
|
}
|
|
if fi != nil && fi.IsDir() {
|
|
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
dstDir := doneAbs
|
|
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
|
http.Error(w, "done subdir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
dst, err := uniqueDestPath(dstDir, file)
|
|
if err != nil {
|
|
http.Error(w, "zielname nicht verfügbar: "+err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
http.Error(w, "unkeep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "unkeep fehlgeschlagen: "+file, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"oldFile": file,
|
|
"newFile": filepath.Base(dst),
|
|
})
|
|
}
|
|
|
|
func recordKeepVideo(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
file, ok, err := safeBasenameQuery(r, "file")
|
|
if err != nil || !ok {
|
|
http.Error(w, "file fehlt/ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isAllowedVideoExt(file) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
releaseFileForMutation(file)
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
keepRoot := filepath.Join(doneAbs, "keep")
|
|
if err := os.MkdirAll(keepRoot, 0o755); err != nil {
|
|
http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// already in keep?
|
|
if p, _, ok := findFileInDirOrOneLevelSubdirs(keepRoot, file, ""); ok {
|
|
if strings.EqualFold(filepath.Clean(filepath.Dir(p)), filepath.Clean(keepRoot)) {
|
|
modelKey := modelKeyFromFilenameOrPath(file, p, keepRoot)
|
|
modelKey = sanitizeModelKey(modelKey)
|
|
if modelKey == "" {
|
|
stem := strings.TrimSuffix(file, filepath.Ext(file))
|
|
modelKey = sanitizeModelKey(modelNameFromFilename(stem))
|
|
}
|
|
if modelKey != "" {
|
|
dstDir := filepath.Join(keepRoot, modelKey)
|
|
if err := os.MkdirAll(dstDir, 0o755); err == nil {
|
|
dst, derr := uniqueDestPath(dstDir, file)
|
|
if derr == nil {
|
|
_ = renameWithRetry(p, dst)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"file": file,
|
|
"alreadyKept": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
src, fi, ok2 := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep")
|
|
if !ok2 {
|
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if fi == nil || fi.IsDir() {
|
|
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
modelKey := modelKeyFromFilenameOrPath(file, src, doneAbs)
|
|
dstDir := keepRoot
|
|
if modelKey != "" {
|
|
dstDir = filepath.Join(keepRoot, modelKey)
|
|
}
|
|
|
|
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
|
http.Error(w, "keep subdir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
dst, err := uniqueDestPath(dstDir, file)
|
|
if err != nil {
|
|
http.Error(w, "zielname nicht verfügbar: "+err.Error(), http.StatusConflict)
|
|
return
|
|
}
|
|
|
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
http.Error(w, "keep fehlgeschlagen (Datei wird gerade verwendet).", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "keep fehlgeschlagen: "+file, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"file": file,
|
|
"alreadyKept": false,
|
|
"newFile": filepath.Base(dst),
|
|
})
|
|
}
|
|
|
|
func recordToggleHot(w http.ResponseWriter, r *http.Request) {
|
|
if !mustMethod(w, r, http.MethodPost) {
|
|
return
|
|
}
|
|
|
|
file, ok, err := safeBasenameQuery(r, "file")
|
|
if err != nil || !ok {
|
|
http.Error(w, "file fehlt/ungültig", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !isAllowedVideoExt(file) {
|
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
releaseFileForMutation(file)
|
|
|
|
s := getSettings()
|
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
|
if err != nil {
|
|
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if strings.TrimSpace(doneAbs) == "" {
|
|
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
src, from, fi, err := resolveDoneFileByName(doneAbs, file)
|
|
if err != nil {
|
|
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
|
|
return
|
|
}
|
|
if fi != nil && fi.IsDir() {
|
|
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
srcDir := filepath.Dir(src)
|
|
|
|
newFile := file
|
|
if strings.HasPrefix(file, "HOT ") {
|
|
newFile = strings.TrimPrefix(file, "HOT ")
|
|
} else {
|
|
newFile = "HOT " + file
|
|
}
|
|
|
|
dst := filepath.Join(srcDir, newFile)
|
|
if _, err := os.Stat(dst); err == nil {
|
|
http.Error(w, "ziel existiert bereits", http.StatusConflict)
|
|
return
|
|
} else if !os.IsNotExist(err) {
|
|
http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := renameWithRetryAggressive(src, dst); err != nil {
|
|
if runtime.GOOS == "windows" && isSharingViolation(err) {
|
|
http.Error(w, "rename fehlgeschlagen (Datei wird gerade abgespielt). Bitte erneut versuchen.", http.StatusConflict)
|
|
return
|
|
}
|
|
http.Error(w, "rename fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Wichtig:
|
|
// Generated-Assets / Meta bleiben auf der kanonischen ID OHNE "HOT ".
|
|
canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file)))
|
|
|
|
renameJobsOutputBasename(file, newFile)
|
|
|
|
notifyDoneChanged()
|
|
|
|
respondJSON(w, map[string]any{
|
|
"ok": true,
|
|
"oldFile": file,
|
|
"newFile": newFile,
|
|
"canonicalID": canonicalID,
|
|
"from": from,
|
|
})
|
|
}
|