package main import ( "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" "os" "path" "path/filepath" "runtime" "sort" "strconv" "strings" "sync/atomic" "time" ) 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 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 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 } func isSafeRelDir(rel string) bool { rel = strings.TrimSpace(rel) if rel == "" { return false } // normalize to slash for validation rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "/") { return false } clean := path.Clean(rel) // path.Clean => forward slashes if clean == "." { return true } if strings.HasPrefix(clean, "../") || clean == ".." { return false } // prevent weird traversal if strings.Contains(clean, `\`) { return false } return true } func isSafeBasename(name string) bool { name = strings.TrimSpace(name) if name == "" { return false } if strings.Contains(name, "/") || strings.Contains(name, "\\") { return false } return filepath.Base(name) == name } func recordList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } jobsMu.Lock() list := make([]*RecordJob, 0, len(jobs)) for _, j := range jobs { // ✅ NEU: Hidden (und nil) nicht ausgeben -> UI sieht Probe-Jobs nicht if j == nil || j.Hidden { continue } list = append(list, j) } jobsMu.Unlock() // optional: neueste zuerst sort.Slice(list, func(i, j int) bool { return list[i].StartedAt.After(list[j].StartedAt) }) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(list) } func writeSSE(w http.ResponseWriter, data []byte) { // SSE spec: jede Zeile mit "data:" prefixen s := strings.ReplaceAll(string(data), "\r\n", "\n") lines := strings.Split(s, "\n") for _, line := range lines { fmt.Fprintf(w, "data: %s\n", line) } fmt.Fprint(w, "\n") } func handleDoneStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } ch := make(chan []byte, 16) doneHub.add(ch) defer doneHub.remove(ch) // optional: initial ping/hello, damit Client sofort "lebt" fmt.Fprintf(w, "event: doneChanged\ndata: {\"type\":\"doneChanged\",\"seq\":%d,\"ts\":%d}\n\n", atomic.LoadUint64(&doneSeq), time.Now().UnixMilli()) flusher.Flush() ctx := r.Context() for { select { case <-ctx.Done(): return case b := <-ch: // wichtig: event-name setzen -> Client kann addEventListener("doneChanged", ...) fmt.Fprintf(w, "event: doneChanged\ndata: %s\n\n", b) flusher.Flush() } } } func handleRecordVideo(w http.ResponseWriter, r *http.Request) { // Priorität: id -> (dein bestehendes Mapping), sonst file id := strings.TrimSpace(r.URL.Query().Get("id")) if id != "" { // ✅ wenn du schon eine bestehende Logik hast: Pfad aus JobStore holen und dann ServeContent nutzen // path := lookupPathByJobID(id) // ... } file := strings.TrimSpace(r.URL.Query().Get("file")) if file == "" && id == "" { http.Error(w, "missing id or file", http.StatusBadRequest) return } var path string var err error if file != "" { path, err = findVideoPath(file) if err != nil { http.NotFound(w, r) return } } else { // TODO: wenn id verwendet wurde, path hier setzen http.NotFound(w, r) return } f, err := openForReadShareDelete(path) if err != nil { http.Error(w, "open failed", http.StatusInternalServerError) return } defer f.Close() st, err := f.Stat() if err != nil { http.Error(w, "stat failed", http.StatusInternalServerError) return } // ✅ wichtig für Browser/VideoJS ext := strings.ToLower(filepath.Ext(path)) switch ext { case ".ts": w.Header().Set("Content-Type", "video/mp2t") default: w.Header().Set("Content-Type", "video/mp4") } w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Cache-Control", "no-store") // ✅ Range/206/Seeking korrekt http.ServeContent(w, r, filepath.Base(path), st.ModTime(), f) } func startRecordingFromRequest(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) 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 } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(job) } func recordVideo(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin != "" { // ✅ dev origin erlauben (oder "*" wenn’s dir egal ist) 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") w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges") } if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } // ✅ Wiedergabe über Dateiname (für doneDir / recordDir) if raw := strings.TrimSpace(r.URL.Query().Get("file")); raw != "" { // explizit decoden (zur Sicherheit) file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // kein Pfad, keine Backslashes, kein Traversal if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil { http.Error(w, "recordDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // Kandidaten: erst done (inkl. 1 Level Subdir, aber ohne "keep"), // dann keep (inkl. 1 Level Subdir), dann recordDir names := []string{file} // Falls UI noch ".ts" kennt, die Datei aber schon als ".mp4" existiert: if ext == ".ts" { mp4File := strings.TrimSuffix(file, ext) + ".mp4" names = append(names, mp4File) } var outPath string for _, name := range names { // done root + done// (skip "keep") if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok { outPath = p break } // keep root + keep// if p, _, ok := findFileInDirOrOneLevelSubdirs(filepath.Join(doneAbs, "keep"), name, ""); ok { outPath = p break } // record root (+ optional 1 Level Subdir) if p, _, ok := findFileInDirOrOneLevelSubdirs(recordAbs, name, ""); ok { outPath = p break } } if outPath == "" { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } // TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4 if strings.ToLower(filepath.Ext(outPath)) == ".ts" { newOut, err := maybeRemuxTS(outPath) if err != nil { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(newOut) == "" { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError) return } outPath = newOut // sicherstellen, dass wirklich eine MP4 existiert fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" { http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError) 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 { http.Error(w, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } outPath = newOut case "html": http.Error(w, "Server liefert HTML statt Video (Pfad/Lookup prüfen)", http.StatusInternalServerError) return } } w.Header().Set("Cache-Control", "no-store") serveVideoFile(w, r, outPath) return } // ✅ ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) id := strings.TrimSpace(r.URL.Query().Get("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 } outPath := filepath.Clean(strings.TrimSpace(job.Output)) if outPath == "" { http.Error(w, "output fehlt", http.StatusNotFound) return } if !filepath.IsAbs(outPath) { abs, err := resolvePathRelativeToApp(outPath) if err != nil { http.Error(w, "pfad auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } outPath = abs } fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } // TS kann der Browser nicht zuverlässig direkt -> on-demand remux nach MP4 if strings.ToLower(filepath.Ext(outPath)) == ".ts" { newOut, err := maybeRemuxTS(outPath) if err != nil { http.Error(w, "TS Remux fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } if strings.TrimSpace(newOut) == "" { http.Error(w, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt", http.StatusInternalServerError) return } outPath = newOut fi, err := os.Stat(outPath) if err != nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" { http.Error(w, "Remux-Ergebnis ungültig", http.StatusInternalServerError) return } } serveVideoFile(w, r, outPath) } func recordStatus(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("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 } json.NewEncoder(w).Encode(job) } func recordStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST", http.StatusMethodNotAllowed) return } id := r.URL.Query().Get("id") jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return } stopJobsInternal([]*RecordJob{job}) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(job) } func recordDoneList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) return } // ✅ optional: auch /done/keep/ einbeziehen (Standard: false) qKeep := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("includeKeep"))) includeKeep := qKeep == "1" || qKeep == "true" || qKeep == "yes" // ✅ NEU: optionaler Model-Filter (Pagination dann "pro Model" sinnvoll) normalizeQueryModel := func(raw string) string { s := strings.TrimSpace(raw) if s == "" { return "" } s = strings.TrimPrefix(s, "http://") s = strings.TrimPrefix(s, "https://") // letzter URL-Segment, falls jemand "…/modelname" übergibt 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 } } } // falls "host:model" übergeben wird if strings.Contains(s, ":") { s = strings.TrimSpace(strings.Split(s, ":")[len(strings.Split(s, ":"))-1]) } s = strings.TrimPrefix(s, "@") return strings.ToLower(strings.TrimSpace(s)) } qModel := normalizeQueryModel(r.URL.Query().Get("model")) // optional: Pagination (1-based). Wenn page/pageSize fehlen -> wie vorher: komplette Liste 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 } } // optional: Sort // supported: completed_(asc|desc), model_(asc|desc), file_(asc|desc), duration_(asc|desc), size_(asc|desc) sortMode := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort"))) if sortMode == "" { sortMode = "completed_desc" } // ⚠️ Backwards-Compat: alte model_* Sorts auf file_* mappen if sortMode == "model_asc" { sortMode = "file_asc" } if sortMode == "model_desc" { sortMode = "file_desc" } // ✅ all=1 -> immer komplette Liste zurückgeben (Pagination deaktivieren) qAll := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("all"))) fetchAll := qAll == "1" || qAll == "true" || qAll == "yes" if fetchAll { page = 0 pageSize = 0 } // ✅ .trash niemals als "done item" zählen/listen isTrashOutput := func(p string) bool { pp := strings.ToLower(filepath.ToSlash(strings.TrimSpace(p))) return strings.Contains(pp, "/.trash/") || strings.HasSuffix(pp, "/.trash") } // --- helpers (ModelKey aus Filename/Dir ableiten) --- modelFromStem := func(stem string) string { // stem: lower, ohne ext, ohne HOT if stem == "" { return "" } if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { return strings.ToLower(strings.TrimSpace(m[1])) } // fallback: alles vor letztem "_" (oder kompletter stem) if i := strings.LastIndex(stem, "_"); i > 0 { return strings.ToLower(strings.TrimSpace(stem[:i])) } return strings.ToLower(strings.TrimSpace(stem)) } modelFromFullPath := func(full string) string { name := strings.ToLower(filepath.Base(full)) stem := strings.TrimSuffix(name, filepath.Ext(name)) stem = strings.TrimPrefix(stem, "hot ") mk := modelFromStem(stem) // fallback: wenn Dateiname nichts taugt, aus Ordner nehmen (/done//file) if mk == "" { parent := strings.ToLower(filepath.Base(filepath.Dir(full))) parent = strings.TrimSpace(parent) if parent != "" && parent != "keep" { mk = parent } } return mk } isTrashPath := func(full string) bool { p := strings.ReplaceAll(full, "\\", "/") // match: ".../.trash/file.ext" oder ".../.trash" return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") } // --- resolve done path --- s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } // Wenn kein DoneDir gesetzt ist → einfach leere Liste zurückgeben if strings.TrimSpace(doneAbs) == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(doneListResponse{ Items: []*RecordJob{}, TotalCount: 0, Page: page, PageSize: pageSize, }) return } type scanDir struct { dir string skipKeep bool // nur für doneAbs: "keep" nicht doppelt scannen } dirs := []scanDir{{dir: doneAbs, skipKeep: true}} if includeKeep { dirs = append(dirs, scanDir{dir: filepath.Join(doneAbs, "keep"), skipKeep: false}) } list := make([]*RecordJob, 0, 256) addFile := func(full string, fi os.FileInfo) { // ✅ .trash niemals zählen / zurückgeben if isTrashPath(full) { return } name := filepath.Base(full) ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return } // ✅ .trash aus Done-Liste ausschließen (auch für totalCount/tab counter) if isTrashOutput(full) { return } // ✅ NEU: Model-Filter vor dem teureren Meta-Kram if qModel != "" { if mk := modelFromFullPath(full); mk != qModel { return } } base := strings.TrimSuffix(name, filepath.Ext(name)) t := fi.ModTime() // StartedAt aus Dateiname (Fallback: ModTime) start := t stem := base if strings.HasPrefix(stem, "HOT ") { stem = strings.TrimPrefix(stem, "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) } dur := 0.0 // 1) meta.json aus generated//meta.json lesen (schnell) id := stripHotPrefix(strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))) srcURL := "" 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 } } } // 2) Fallback: RAM-Cache only (immer noch schnell, kein ffprobe) if dur <= 0 { dur = durationSecondsCacheOnly(full, fi) } // 3) KEIN ffprobe hier! (sonst wird die API wieder langsam) list = append(list, &RecordJob{ ID: base, Output: full, SourceURL: srcURL, Status: JobFinished, StartedAt: start, EndedAt: &t, DurationSeconds: dur, SizeBytes: fi.Size(), }) } for _, sd := range dirs { entries, err := os.ReadDir(sd.dir) if err != nil { if os.IsNotExist(err) { if sd.dir == doneAbs { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(doneListResponse{ Items: []*RecordJob{}, TotalCount: 0, Page: page, PageSize: pageSize, }) return } continue } if sd.dir == doneAbs { http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } continue } for _, e := range entries { // Subdir: 1 Level rein (z.B. /done// oder /done/keep//) if e.IsDir() { // ✅ .trash Ordner niemals scannen if e.Name() == ".trash" { continue } if sd.skipKeep && e.Name() == "keep" { continue } // ✅ .trash nie scannen if strings.EqualFold(e.Name(), ".trash") { continue } sub := filepath.Join(sd.dir, e.Name()) subEntries, err := os.ReadDir(sub) if err != nil { continue } for _, se := range subEntries { if se.IsDir() { continue } full := filepath.Join(sub, se.Name()) fi, err := os.Stat(full) if err != nil || fi.IsDir() || fi.Size() == 0 { continue } addFile(full, fi) } continue } full := filepath.Join(sd.dir, e.Name()) fi, err := os.Stat(full) if err != nil || fi.IsDir() || fi.Size() == 0 { continue } addFile(full, fi) } } // helpers (Sort) fileForSort := func(j *RecordJob) string { f := strings.ToLower(filepath.Base(j.Output)) // HOT Prefix aus Sortierung rausnehmen f = strings.TrimPrefix(f, "hot ") return f } durationForSort := func(j *RecordJob) (sec float64, ok bool) { if j.DurationSeconds > 0 { return j.DurationSeconds, true } return 0, false } // Sortierung sort.Slice(list, func(i, j int) bool { a, b := list[i], list[j] ta, tb := time.Time{}, time.Time{} if a.EndedAt != nil { ta = *a.EndedAt } if b.EndedAt != nil { tb = *b.EndedAt } switch sortMode { case "completed_asc": if !ta.Equal(tb) { return ta.Before(tb) } return fileForSort(a) < fileForSort(b) case "completed_desc": if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "file_asc": fa, fb := fileForSort(a), fileForSort(b) if fa != fb { return fa < fb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "file_desc": fa, fb := fileForSort(a), fileForSort(b) if fa != fb { return fa > fb } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "duration_asc": da, okA := durationForSort(a) db, okB := durationForSort(b) if okA != okB { return okA // unbekannt nach hinten } if okA && okB && da != db { return da < db } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) 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 fileForSort(a) < fileForSort(b) case "size_asc": if a.SizeBytes != b.SizeBytes { return a.SizeBytes < b.SizeBytes } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) case "size_desc": if a.SizeBytes != b.SizeBytes { return a.SizeBytes > b.SizeBytes } if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) default: if !ta.Equal(tb) { return ta.After(tb) } return fileForSort(a) < fileForSort(b) } }) // ✅ optional: count mitsenden qWithCount := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("withCount"))) withCount := qWithCount == "1" || qWithCount == "true" || qWithCount == "yes" // ✅ Gesamtanzahl IMMER vor Pagination merken totalCount := len(list) // ✅ Pagination nur auf "items" anwenden (list bleibt für totalCount intakt) items := list if pageSize > 0 && !fetchAll { if page <= 0 { page = 1 } start := (page - 1) * pageSize if start < 0 { start = 0 } if start >= totalCount { items = []*RecordJob{} } else { end := start + pageSize if end > totalCount { end = totalCount } items = list[start:end] } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") // ✅ Wenn Frontend "withCount=1" nutzt: {count, items} if withCount { _ = json.NewEncoder(w).Encode(map[string]any{ "count": totalCount, "items": items, }) return } // ✅ Standard-Response: immer auch totalCount mitsenden _ = json.NewEncoder(w).Encode(doneListResponse{ Items: items, TotalCount: totalCount, Page: page, PageSize: pageSize, }) return } func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { // Frontend nutzt aktuell POST (siehe FinishedDownloads), daher erlauben wir POST + DELETE if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } // sicher decoden file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben (keine Unterordner, kein Traversal) if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { 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 } // ✅ done + done/ sowie keep + keep/ 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 } // ✅ Single-slot Trash: immer nur die *zuletzt* gelöschte Datei erlauben trashDir := filepath.Join(doneAbs, ".trash") // ✅ Wenn im Single-slot Trash schon was liegt: ID merken, // aber generated erst löschen, NACHDEM .trash wirklich erfolgreich geleert wurde. 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) } } } // Trash komplett leeren => ältere Undos sind automatisch ungültig // ⚠️ Fehler NICHT schlucken: wenn .trash nicht leerbar ist, darf der neue Delete nicht weiterlaufen. 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 } // ✅ Jetzt ist das alte Trash-Video wirklich endgültig weg → generated/meta// entfernen. if prevCanonical != "" { removeGeneratedForID(prevCanonical) // Best-effort: falls irgendwo mal Assets mit HOT-ID entstanden sind 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 } // Original-Dir relativ zu doneAbs merken (inkl. keep/ oder ) 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 = "." } // ✅ Undo-Token jetzt schon erzeugen, damit wir es als "Single-slot key" speichern können tok, err := encodeUndoDeleteToken(undoDeleteToken{ Trash: "", // setzen wir gleich (trashName) RelDir: relDir, // hast du oben schon berechnet File: file, }) if err != nil { http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } trashName := tok + "__" + file // eindeutig + Token sichtbar in filename trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_") dst := filepath.Join(trashDir, trashName) // ✅ Token muss auch wissen, wie der Trashname heißt // (wir encoden den Token nicht neu — wir speichern Trashname separat in last.json) // move mit retry (Windows file-lock robust) if err := renameWithRetry(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 } // ✅ last.json schreiben: nur dieser Token ist gültig type trashMeta struct { Token string `json:"token"` // exakt der Query-Token (encoded) TrashName string `json:"trashName"` // Dateiname in .trash RelDir string `json:"relDir"` // ursprünglicher Ordner relativ zu doneAbs File string `json:"file"` // originaler Name (basename) 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) // Cache/Jobs aufräumen (Assets NICHT hart löschen => Undo bleibt “schnell” möglich) purgeDurationCacheForPath(target) removeJobsByOutputBasename(file) notifyDoneChanged() notifyJobsChanged() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "from": from, // "done" | "keep" "undoToken": tok, // ✅ für Undo "trashed": true, }) } func recordRestoreVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("token")) if raw == "" { http.Error(w, "token fehlt", http.StatusBadRequest) return } // ✅ doneDir auflösen 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 } // ✅ Single-slot: last.json lesen und Token strikt validieren 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 } // ✅ Nur der letzte Token ist gültig if raw != meta.Token { http.Error(w, "token ungültig (nicht der letzte)", http.StatusNotFound) return } // ✅ Token zusätzlich decoden (Format/Signatur prüfen, aber Restore-Daten kommen aus last.json) tok, err := decodeUndoDeleteToken(raw) if err != nil { http.Error(w, "token ungültig", http.StatusBadRequest) return } // ✅ Safety: nur sichere Pfad-Bestandteile aus meta verwenden if !isSafeBasename(meta.TrashName) || !isSafeBasename(meta.File) || !isSafeRelDir(meta.RelDir) { http.Error(w, "token inhalt ungültig", http.StatusBadRequest) return } // ✅ Extra Konsistenzchecks: token.File / token.RelDir müssen zu meta passen (optional aber sinnvoll) if tok.File != meta.File || tok.RelDir != meta.RelDir { http.Error(w, "token passt nicht zu letzter Löschung", http.StatusNotFound) return } ext := strings.ToLower(filepath.Ext(meta.File)) if ext != ".mp4" && ext != ".ts" { http.Error(w, "nicht erlaubt", http.StatusForbidden) return } // Quelle: exakt die zuletzt gelöschte Datei src := filepath.Join(trashDir, meta.TrashName) // Zielordner rekonstruieren (relativ zu doneAbs) rel := meta.RelDir if rel == "." { rel = "" } dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel)) dstDirClean := filepath.Clean(dstDir) doneClean := filepath.Clean(doneAbs) // safety: dstDir muss innerhalb doneAbs liegen 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 := renameWithRetry(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 } // ✅ Optional: Trash leeren, damit Token danach definitiv tot ist _ = os.RemoveAll(trashDir) _ = os.MkdirAll(trashDir, 0o755) notifyDoneChanged() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": meta.File, "restoredFile": filepath.Base(dst), // kann __dup enthalten }) } func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) if !isSafeBasename(file) { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { 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 muss in keep (root oder keep/) liegen 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 } // Ziel: zurück nach done/ (flach, ohne model-subdirs) 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 := renameWithRetry(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: "+err.Error(), http.StatusInternalServerError) return } notifyDoneChanged() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "oldFile": file, "newFile": filepath.Base(dst), }) } func recordKeepVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { 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 } // ✅ 0) Wenn schon irgendwo in keep (root oder keep/) existiert: // - wenn im keep-root: jetzt nach keep// nachziehen if p, _, ok := findFileInDirOrOneLevelSubdirs(keepRoot, file, ""); ok { // p liegt entweder in keepRoot oder keepRoot/ if strings.EqualFold(filepath.Clean(filepath.Dir(p)), filepath.Clean(keepRoot)) { // im Root => versuchen einzusortieren modelKey := modelKeyFromFilenameOrPath(file, p /* srcPath */, keepRoot /* doneAbs dummy, wird nicht genutzt */) modelKey = sanitizeModelKey(modelKey) // Optionaler Fallback: wenn wir aus dem keep-root Pfad nix ziehen können, nur aus Filename: 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 { // best-effort move _ = renameWithRetry(p, dst) } } } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "alreadyKept": true, }) return } // ✅ 1) Quelle in done (root oder done/), aber NICHT aus keep src, fi, ok := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep") if !ok { http.Error(w, "datei nicht gefunden", http.StatusNotFound) return } if fi == nil || fi.IsDir() { http.Error(w, "ist ein verzeichnis", http.StatusBadRequest) return } // ✅ 2) Ziel: keep//file 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 } // rename mit retry (Windows file-lock) if err := renameWithRetry(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: "+err.Error(), http.StatusInternalServerError) return } notifyDoneChanged() // ... dein bestehender Cleanup-Block (generated Assets löschen, legacy cleanup, removeJobsByOutputBasename) bleibt unverändert ... w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, "alreadyKept": false, "newFile": filepath.Base(dst), // ✅ NEU }) } func recordToggleHot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST", http.StatusMethodNotAllowed) return } raw := strings.TrimSpace(r.URL.Query().Get("file")) if raw == "" { http.Error(w, "file fehlt", http.StatusBadRequest) return } file, err := url.QueryUnescape(raw) if err != nil { http.Error(w, "ungültiger file", http.StatusBadRequest) return } file = strings.TrimSpace(file) // ✅ nur Basename erlauben if file == "" || strings.Contains(file, "/") || strings.Contains(file, "\\") || filepath.Base(file) != file { http.Error(w, "ungültiger file", http.StatusBadRequest) return } ext := strings.ToLower(filepath.Ext(file)) if ext != ".mp4" && ext != ".ts" { 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/, done/, keep/, 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) // ✅ wichtig: toggeln im tatsächlichen Ordner // toggle: HOT Prefix newFile := file if strings.HasPrefix(file, "HOT ") { newFile = strings.TrimPrefix(file, "HOT ") } else { newFile = "HOT " + file } dst := filepath.Join(srcDir, newFile) // ✅ im selben Ordner toggeln (done oder keep) 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 := renameWithRetry(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 } // ✅ KEIN generated-rename! // Assets bleiben canonical (ohne HOT) canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file))) renameJobsOutputBasename(file, newFile) notifyDoneChanged() notifyJobsChanged() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "oldFile": file, "newFile": newFile, "canonicalID": canonicalID, "from": from, // "done" | "keep" }) }