nsfwapp/backend/record.go
2026-03-07 13:21:31 +01:00

1989 lines
47 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"
)
// ---------------- Types ----------------
type RecordRequest struct {
URL string `json:"url"`
Cookie string `json:"cookie,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
Hidden bool `json:"hidden,omitempty"`
}
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 doneMetaFileResp struct {
File string `json:"file"`
MetaExists bool `json:"metaExists"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
PreviewSprite previewSpriteMetaResp `json:"previewSprite"`
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"
}
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
}
// ---------------- 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
}
ps, ok := m["previewSprite"].(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
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return out
}
genDir := filepath.Dir(metaPath)
spriteFile := filepath.Join(genDir, "preview-sprite.webp")
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 applyPreviewSpriteTruthToDoneMetaResp(id string, resp *doneMetaFileResp) {
if resp == nil {
return
}
resp.PreviewSprite = previewSpriteTruthForID(id)
}
// 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
}
base := filepath.Base(outPath)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
id = strings.TrimSpace(id)
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{}
}
delete(meta, "previewScrubberPath")
delete(meta, "previewScrubberCount")
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
}
}
meta["previewSprite"] = psMap
setStructFieldJSONMap(fv, meta)
}
// ---------------- Handlers ----------------
func recordList(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) {
return
}
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil || j.Hidden {
continue
}
list = append(list, j)
}
jobsMu.Unlock()
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 recordVideo(w http.ResponseWriter, r *http.Request) {
tw := &rwTrack{ResponseWriter: w}
w = tw
writeErr := func(code int, msg string) {
if tw.wrote {
fmt.Println("[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" {
newOut, err := maybeRemuxTS(outPath)
if err != nil {
writeErr(http.StatusInternalServerError, "TS Remux fehlgeschlagen: "+err.Error())
return
}
if strings.TrimSpace(newOut) == "" {
writeErr(http.StatusInternalServerError, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt")
return
}
outPath = filepath.Clean(strings.TrimSpace(newOut))
fi, err := os.Stat(outPath)
if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" {
writeErr(http.StatusInternalServerError, "Remux-Ergebnis ungültig")
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":
newOut, err := maybeRemuxTS(outPath)
if err != nil {
writeErr(http.StatusInternalServerError, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error())
return
}
outPath = filepath.Clean(strings.TrimSpace(newOut))
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.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
applyPreviewSpriteTruthToRecordJobMeta(job)
respondJSON(w, job)
}
func recordStop(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodPost) {
return
}
id := q(r, "id")
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
stopJobsInternal([]*RecordJob{job})
respondJSON(w, job)
}
// ---------------- 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))
}
// 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)
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))
stem := strings.TrimPrefix(base, "HOT ")
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: base,
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") {
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
}
// ---------------- Done meta + list ----------------
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))
if strings.ToLower(filepath.Ext(outPath)) == ".ts" {
if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" {
outPath = filepath.Clean(strings.TrimSpace(newOut))
if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil {
fi = nfi
}
}
}
ensureMetaJSONForPlayback(r.Context(), outPath)
resp := doneMetaFileResp{File: filepath.Base(outPath)}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath)))
applyPreviewSpriteTruthToDoneMetaResp(id, &resp)
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 {
resp.MetaExists = true
if dur, w2, h2, fps2, ok := readVideoMeta(mp, fi); ok {
resp.DurationSeconds = dur
resp.Width = w2
resp.Height = h2
resp.FPS = fps2
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok {
resp.SourceURL = u
}
}
}
}
if resp.DurationSeconds <= 0 {
pctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if d, derr := durationSecondsCached(pctx, outPath); derr == nil && d > 0 {
resp.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()
count := 0
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
count = len(sortedAll[incKey+"|completed_desc"])
} else {
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
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])
})
}
totalCount := len(idx)
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 idx[start:end] {
base := items[ii].job
if base == nil {
continue
}
c := *base
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 {
c.SizeBytes = fi.Size()
}
id := stripHotPrefix(strings.TrimSuffix(filepath.Base(c.Output), filepath.Ext(c.Output)))
if id != "" {
if mp, err := generatedMetaFile(id); err == nil {
if fi, err := os.Stat(c.Output); err == nil && fi != nil && !fi.IsDir() {
if dur, w, h, fps, ok := readVideoMeta(mp, fi); ok {
c.DurationSeconds = dur
c.VideoWidth = w
c.VideoHeight = h
c.FPS = fps
}
if u, ok := readVideoMetaSourceURL(mp, fi); ok && strings.TrimSpace(c.SourceURL) == "" {
c.SourceURL = u
}
}
}
}
applyPreviewSpriteTruthToRecordJobMeta(&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 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
}
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")
prevBase := ""
prevCanonical := ""
if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 {
var prev struct {
File string `json:"file"`
}
if err := json.Unmarshal(b, &prev); err == nil {
prevFile := strings.TrimSpace(prev.File)
if prevFile != "" {
prevBase = strings.TrimSuffix(prevFile, filepath.Ext(prevFile))
prevCanonical = stripHotPrefix(prevBase)
}
}
}
if err := os.RemoveAll(trashDir); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "konnte .trash nicht leeren (Datei wird gerade verwendet). Bitte Player schließen und erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "trash leeren fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if prevCanonical != "" {
removeGeneratedForID(prevCanonical)
if prevBase != "" && prevBase != prevCanonical {
removeGeneratedForID(prevBase)
}
}
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,
})
if err != nil {
http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
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
}
type trashMeta struct {
Token string `json:"token"`
TrashName string `json:"trashName"`
RelDir string `json:"relDir"`
File string `json:"file"`
DeletedAt int64 `json:"deletedAt"`
}
meta := trashMeta{
Token: tok,
TrashName: trashName,
RelDir: relDir,
File: file,
DeletedAt: time.Now().Unix(),
}
b, _ := json.Marshal(meta)
_ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644)
purgeDurationCacheForPath(target)
removeJobsByOutputBasename(file)
notifyDoneChanged()
notifyJobsChanged()
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
}
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")
metaPath := filepath.Join(trashDir, "last.json")
b, err := os.ReadFile(metaPath)
if err != nil {
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
return
}
var meta struct {
Token string `json:"token"`
TrashName string `json:"trashName"`
RelDir string `json:"relDir"`
File string `json:"file"`
DeletedAt int64 `json:"deletedAt"`
}
if err := json.Unmarshal(b, &meta); err != nil {
http.Error(w, "trash meta ungültig", http.StatusInternalServerError)
return
}
if strings.TrimSpace(meta.Token) == "" || strings.TrimSpace(meta.TrashName) == "" || strings.TrimSpace(meta.File) == "" {
http.Error(w, "trash meta unvollständig", http.StatusInternalServerError)
return
}
if raw != meta.Token {
http.Error(w, "token ungültig (nicht der letzte)", http.StatusNotFound)
return
}
tok, err := decodeUndoDeleteToken(raw)
if err != nil {
http.Error(w, "token ungültig", http.StatusBadRequest)
return
}
if !isSafeBasename(meta.TrashName) || !isSafeBasename(meta.File) || !isSafeRelDir(meta.RelDir) {
http.Error(w, "token inhalt ungültig", http.StatusBadRequest)
return
}
if tok.File != meta.File || tok.RelDir != meta.RelDir {
http.Error(w, "token passt nicht zu letzter Löschung", http.StatusNotFound)
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.RemoveAll(trashDir)
_ = os.MkdirAll(trashDir, 0o755)
purgeDurationCacheForPath(src)
purgeDurationCacheForPath(dst)
notifyDoneChanged()
respondJSON(w, map[string]any{
"ok": true,
"file": meta.File,
"restoredFile": filepath.Base(dst),
})
}
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
}
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
}
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
}
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
}
// Quelle kann in done/ oder keep/ liegen
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
}
canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file)))
renameJobsOutputBasename(file, newFile)
notifyDoneChanged()
notifyJobsChanged()
respondJSON(w, map[string]any{
"ok": true,
"oldFile": file,
"newFile": newFile,
"canonicalID": canonicalID,
"from": from,
})
}