1612 lines
43 KiB
Go
1612 lines
43 KiB
Go
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/<subdir>/ (skip "keep")
|
||
if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok {
|
||
outPath = p
|
||
break
|
||
}
|
||
// keep root + keep/<subdir>/
|
||
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/<model>/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/<id>/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/<model>/ oder /done/keep/<model>/)
|
||
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/<subdir> sowie keep + keep/<subdir>
|
||
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/<id>/ 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/<subdir> oder <subdir>)
|
||
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/<subdir>) 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/<subdir>) existiert:
|
||
// - wenn im keep-root: jetzt nach keep/<model>/ nachziehen
|
||
if p, _, ok := findFileInDirOrOneLevelSubdirs(keepRoot, file, ""); ok {
|
||
// p liegt entweder in keepRoot oder keepRoot/<subdir>
|
||
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/<subdir>), 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/<model>/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/<subdir>, keep/, keep/<subdir> 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"
|
||
})
|
||
}
|