// 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 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) } // SSE (done stream) func handleDoneStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } ch := make(chan []byte, 32) doneHub.add(ch) defer doneHub.remove(ch) fmt.Fprintf(w, ": hello seq=%d ts=%d\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli()) flusher.Flush() ctx := r.Context() ping := time.NewTicker(15 * time.Second) defer ping.Stop() for { select { case <-ctx.Done(): return case <-ping.C: fmt.Fprintf(w, ": ping ts=%d\n\n", time.Now().UnixMilli()) flusher.Flush() case b, ok := <-ch: if !ok { return } fmt.Fprintf(w, "event: doneChanged\n") fmt.Fprintf(w, "data: %s\n\n", b) flusher.Flush() } } } 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//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: "|" } 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, }) }