nsfwapp/backend/record.go
2026-03-06 14:50:56 +01:00

2072 lines
49 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"
"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 recordJobs(w http.ResponseWriter, r *http.Request) {
if !mustMethod(w, r, http.MethodGet) {
return
}
t0 := time.Now()
jobsMu.Lock()
wait := time.Since(t0)
if wait > 200*time.Millisecond {
fmt.Println("[recordJobs] waited for jobsMu:", wait)
}
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil || j.Hidden {
continue
}
list = append(list, j)
}
jobsMu.Unlock() // ✅ früh unlocken
sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt)
})
respondJSON(w, list)
}
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
doneAbs string
items []doneIndexItem
sortedIdx map[string][]int // key: "<includeKeep 0/1>|<sortMode>"
}
var doneCache doneIndexCache
func invalidateDoneCache() {
doneCache.mu.Lock()
doneCache.builtAt = time.Time{}
doneCache.doneAbs = ""
doneCache.items = nil
doneCache.sortedIdx = nil
doneCache.mu.Unlock()
}
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
}
now := time.Now()
doneCache.mu.Lock()
needRebuild := 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.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
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})
}
// getDoneCountFast nutzt dieselbe Cache/Index-Logik wie recordDoneMeta (Count-Mode),
// aber ohne http.ResponseWriter/Request.
// includeKeep=false, model="" entspricht deinem Badge im Tab.
func getDoneCountFast() int {
// gleiche Defaults wie Frontend-Badge: includeKeep=false, model=""
includeKeep := false
qModel := "" // kein model-filter
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
return 0
}
now := time.Now()
doneCache.mu.Lock()
needRebuild := 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.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
doneCache.doneAbs = doneAbs
doneCache.builtAt = now
}
}
items := doneCache.items
sortedAll := doneCache.sortedIdx
doneCache.mu.Unlock()
// Count berechnen (wie in recordDoneMeta)
if qModel == "" {
incKey := "0"
if includeKeep {
incKey = "1"
}
return len(sortedAll[incKey+"|completed_desc"])
}
// (optional) model filter hier aktuell nicht gebraucht
cnt := 0
for _, it := range items {
if !includeKeep && it.fromKeep {
continue
}
if it.modelKey == qModel {
cnt++
}
}
return cnt
}
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
}
now := time.Now()
doneCache.mu.Lock()
needRebuild := 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.doneAbs = doneAbs
doneCache.builtAt = now
} else {
items, sorted := buildDoneIndex(doneAbs)
doneCache.items = items
doneCache.sortedIdx = sorted
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)
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
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)
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
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
}
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
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
}
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
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)
invalidateDoneCache()
notifyDoneChanged()
notifyDoneMetaChanged()
respondJSON(w, map[string]any{
"ok": true,
"oldFile": file,
"newFile": newFile,
"canonicalID": canonicalID,
"from": from,
})
}