From d603dd23425fd327ebfb6c269d92e6ac37da6244 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 19 Dec 2025 23:06:40 +0100 Subject: [PATCH] updated --- backend/main.go | 360 +++++++++++++++--- backend/recorder_settings.json | 7 +- frontend/src/App.tsx | 281 +++++++++----- .../src/components/ui/FinishedDownloads.tsx | 61 ++- .../components/ui/FinishedVideoPreview.tsx | 157 ++++++-- frontend/src/components/ui/HoverPopover.tsx | 63 ++- frontend/src/components/ui/LabeledSwitch.tsx | 78 ++++ frontend/src/components/ui/ModelPreview.tsx | 55 ++- frontend/src/components/ui/Player.tsx | 11 +- .../src/components/ui/RecorderSettings.tsx | 110 ++++-- .../src/components/ui/RunningDownloads.tsx | 19 +- frontend/src/components/ui/Switch.tsx | 174 +++++++++ frontend/src/components/ui/Tabs.tsx | 319 +++++++++++++--- 13 files changed, 1405 insertions(+), 290 deletions(-) create mode 100644 frontend/src/components/ui/LabeledSwitch.tsx create mode 100644 frontend/src/components/ui/Switch.tsx diff --git a/backend/main.go b/backend/main.go index f3cee0f..b96cc3b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -47,8 +47,9 @@ type RecordJob struct { EndedAt *time.Time `json:"endedAt,omitempty"` Error string `json:"error,omitempty"` - PreviewDir string `json:"-"` - previewCmd *exec.Cmd `json:"-"` + PreviewDir string `json:"-"` + PreviewImage string `json:"-"` + previewCmd *exec.Cmd `json:"-"` cancel context.CancelFunc `json:"-"` } @@ -58,16 +59,29 @@ var ( jobsMu = sync.Mutex{} ) +// ffmpeg-Binary suchen (env, neben EXE, oder PATH) +var ffmpegPath = detectFFmpegPath() + +// main.go + type RecorderSettings struct { - RecordDir string `json:"recordDir"` - DoneDir string `json:"doneDir"` + RecordDir string `json:"recordDir"` + DoneDir string `json:"doneDir"` + FFmpegPath string `json:"ffmpegPath,omitempty"` + + AutoAddToDownloadList bool `json:"autoAddToDownloadList,omitempty"` + AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"` } var ( settingsMu sync.Mutex settings = RecorderSettings{ - RecordDir: "/records", - DoneDir: "/records/done", + RecordDir: "/records", + DoneDir: "/records/done", + FFmpegPath: "", + + AutoAddToDownloadList: false, + AutoStartAddedDownloads: false, } settingsFile = "recorder_settings.json" ) @@ -78,6 +92,53 @@ func getSettings() RecorderSettings { return settings } +func detectFFmpegPath() string { + // 0. Settings-Override (ffmpegPath in recorder_settings.json / UI) + s := getSettings() + if p := strings.TrimSpace(s.FFmpegPath); p != "" { + // Relativ zur EXE auflösen, falls nötig + if !filepath.IsAbs(p) { + if abs, err := resolvePathRelativeToExe(p); err == nil { + p = abs + } + } + return p + } + + // 1. Umgebungsvariable FFMPEG_PATH erlaubt Override + if p := strings.TrimSpace(os.Getenv("FFMPEG_PATH")); p != "" { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p + } + + // 2. ffmpeg / ffmpeg.exe im selben Ordner wie dein Go-Programm + if exe, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(exeDir, "ffmpeg"), + filepath.Join(exeDir, "ffmpeg.exe"), + } + for _, c := range candidates { + if fi, err := os.Stat(c); err == nil && !fi.IsDir() { + return c + } + } + } + + // 3. ffmpeg über PATH suchen und absolut machen + if lp, err := exec.LookPath("ffmpeg"); err == nil { + if abs, err2 := filepath.Abs(lp); err2 == nil { + return abs + } + return lp + } + + // 4. Fallback: plain "ffmpeg" – kann dann immer noch fehlschlagen + return "ffmpeg" +} + func loadSettings() { b, err := os.ReadFile(settingsFile) if err == nil { @@ -89,6 +150,10 @@ func loadSettings() { if strings.TrimSpace(s.DoneDir) != "" { s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir)) } + if strings.TrimSpace(s.FFmpegPath) != "" { + s.FFmpegPath = strings.TrimSpace(s.FFmpegPath) + } + settingsMu.Lock() settings = s settingsMu.Unlock() @@ -99,6 +164,10 @@ func loadSettings() { s := getSettings() _ = os.MkdirAll(s.RecordDir, 0o755) _ = os.MkdirAll(s.DoneDir, 0o755) + + // ffmpeg-Pfad anhand Settings/Env/PATH bestimmen + ffmpegPath = detectFFmpegPath() + fmt.Println("🔍 ffmpegPath:", ffmpegPath) } func saveSettingsToDisk() { @@ -123,6 +192,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { in.RecordDir = filepath.Clean(strings.TrimSpace(in.RecordDir)) in.DoneDir = filepath.Clean(strings.TrimSpace(in.DoneDir)) + in.FFmpegPath = strings.TrimSpace(in.FFmpegPath) if in.RecordDir == "" || in.DoneDir == "" { http.Error(w, "recordDir und doneDir dürfen nicht leer sein", http.StatusBadRequest) @@ -144,6 +214,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { settingsMu.Unlock() saveSettingsToDisk() + // ffmpeg-Pfad nach Änderungen neu bestimmen + ffmpegPath = detectFFmpegPath() + fmt.Println("🔍 ffmpegPath (nach Save):", ffmpegPath) + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(getSettings()) return @@ -156,19 +230,35 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { func settingsBrowse(w http.ResponseWriter, r *http.Request) { target := r.URL.Query().Get("target") - if target != "record" && target != "done" { - http.Error(w, "target muss record oder done sein", http.StatusBadRequest) + if target != "record" && target != "done" && target != "ffmpeg" { + http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest) return } - p, err := dialog.Directory().Title("Ordner auswählen").Browse() + var ( + p string + err error + ) + + if target == "ffmpeg" { + // Dateiauswahl für ffmpeg.exe + p, err = dialog.File(). + Title("ffmpeg.exe auswählen"). + Load() + } else { + // Ordnerauswahl für record/done + p, err = dialog.Directory(). + Title("Ordner auswählen"). + Browse() + } + if err != nil { // User cancelled → 204 No Content ist praktisch fürs Frontend if strings.Contains(strings.ToLower(err.Error()), "cancel") { w.WriteHeader(http.StatusNoContent) return } - http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } @@ -301,7 +391,7 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri func remuxTSToMP4(tsPath, mp4Path string) error { // ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4 - cmd := exec.Command("ffmpeg", + cmd := exec.Command(ffmpegPath, "-y", "-i", tsPath, "-c", "copy", @@ -317,10 +407,8 @@ func remuxTSToMP4(tsPath, mp4Path string) error { } func extractLastFrameJPEG(path string) ([]byte, error) { - // “Letzter Frame” über -sseof nahe Dateiende. - // Bei laufenden Aufnahmen kann das manchmal fehlschlagen -> Fallback oben. cmd := exec.Command( - "ffmpeg", + ffmpegPath, "-hide_banner", "-loglevel", "error", "-sseof", "-0.1", @@ -343,6 +431,79 @@ func extractLastFrameJPEG(path string) ([]byte, error) { return out.Bytes(), nil } +func extractFrameAtTimeJPEG(path string, seconds float64) ([]byte, error) { + if seconds < 0 { + seconds = 0 + } + seek := fmt.Sprintf("%.3f", seconds) + + cmd := exec.Command( + ffmpegPath, + "-hide_banner", + "-loglevel", "error", + "-ss", seek, + "-i", path, + "-frames:v", "1", + "-q:v", "4", + "-f", "image2pipe", + "-vcodec", "mjpeg", + "pipe:1", + ) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg frame-at-time: %w (%s)", err, strings.TrimSpace(stderr.String())) + } + return out.Bytes(), nil +} + +// sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts) +func latestPreviewSegment(previewDir string) (string, error) { + entries, err := os.ReadDir(previewDir) + if err != nil { + return "", err + } + + var best string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") { + continue + } + if best == "" || name > best { + best = name + } + } + + if best == "" { + return "", fmt.Errorf("kein Preview-Segment in %s", previewDir) + } + return filepath.Join(previewDir, best), nil +} + +// erzeugt ein JPEG aus dem letzten Preview-Segment +func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) { + seg, err := latestPreviewSegment(previewDir) + if err != nil { + return nil, err + } + + // Segment ist klein und "fertig" – hier reicht ein Last-Frame-Versuch, + // mit Fallback auf First-Frame. + img, err := extractLastFrameJPEG(seg) + if err != nil { + return extractFirstFrameJPEG(seg) + } + return img, nil +} + func recordPreview(w http.ResponseWriter, r *http.Request) { id := r.URL.Query().Get("id") if id == "" { @@ -350,53 +511,132 @@ func recordPreview(w http.ResponseWriter, r *http.Request) { return } - // HLS mode: ?file=index.m3u8 / seg_00001.ts / index_hq.m3u8 ... + // HLS-Dateien (index.m3u8, seg_*.ts) wie bisher if file := r.URL.Query().Get("file"); file != "" { servePreviewHLSFile(w, r, id, file) return } - // sonst: JPEG Fallback + // Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig) jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() - if !ok { - http.Error(w, "job nicht gefunden", http.StatusNotFound) + if ok { + // 1️⃣ Bevorzugt: aktuelles Bild aus HLS-Preview-Segmenten + previewDir := strings.TrimSpace(job.PreviewDir) + if previewDir != "" { + if img, err := extractLastFrameFromPreviewDir(previewDir); err == nil { + // dynamischer Snapshot – Frontend hängt ?v=... dran, + // damit der Browser ihn neu lädt + servePreviewJPEGBytes(w, img) + return + } + } + + // 2️⃣ Fallback: direkt aus der Ausgabedatei (TS/MP4), z.B. wenn Preview noch nicht läuft + outPath := strings.TrimSpace(job.Output) + if outPath == "" { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + outPath = filepath.Clean(outPath) + + if !filepath.IsAbs(outPath) { + if abs, err := resolvePathRelativeToExe(outPath); err == nil { + outPath = abs + } + } + + fi, err := os.Stat(outPath) + if err != nil || fi.IsDir() || fi.Size() == 0 { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + + img, err := extractLastFrameJPEG(outPath) + if err != nil { + img2, err2 := extractFirstFrameJPEG(outPath) + if err2 != nil { + http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError) + return + } + img = img2 + } + + servePreviewJPEGBytes(w, img) return } - outPath := strings.TrimSpace(job.Output) + // 3️⃣ Kein Job im RAM → id als Dateistamm für fertige Downloads behandeln + servePreviewForFinishedFile(w, r, id) +} + +// Fallback: Preview für fertige Dateien nur anhand des Dateistamms (id) +func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id string) { + id = strings.TrimSpace(id) + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + if strings.ContainsAny(id, `/\`) { + http.Error(w, "ungültige id", http.StatusBadRequest) + return + } + + s := getSettings() + recordAbs, _ := resolvePathRelativeToExe(s.RecordDir) + doneAbs, _ := resolvePathRelativeToExe(s.DoneDir) + + candidates := []string{ + filepath.Join(doneAbs, id+".mp4"), + filepath.Join(doneAbs, id+".ts"), + filepath.Join(recordAbs, id+".mp4"), + filepath.Join(recordAbs, id+".ts"), + } + + var outPath string + for _, p := range candidates { + fi, err := os.Stat(p) + if err == nil && !fi.IsDir() && fi.Size() > 0 { + outPath = p + break + } + } + if outPath == "" { http.Error(w, "preview nicht verfügbar", http.StatusNotFound) return } - outPath = filepath.Clean(outPath) - - // ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen. - // (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.) - if !filepath.IsAbs(outPath) { - if outPath == "." || outPath == ".." || - strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) { - http.Error(w, "ungültiger output-pfad", http.StatusBadRequest) - return + // 🔹 NEU: dynamischer Frame an Zeitposition t (z.B. für animierte Thumbnails) + if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" { + if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 { + if img, err := extractFrameAtTimeJPEG(outPath, sec); err == nil { + servePreviewJPEGBytes(w, img) + return + } + // wenn ffmpeg hier scheitert, geht's unten mit statischem Preview weiter } } - fi, err := os.Stat(outPath) - if err != nil { - http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + // 🔸 ALT: einmaliges Preview cachen (preview.jpg) – Fallback + previewDir := filepath.Join(os.TempDir(), "rec_preview", id) + if err := os.MkdirAll(previewDir, 0o755); err != nil { + http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError) return } - if fi.IsDir() || fi.Size() == 0 { - http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + + jpegPath := filepath.Join(previewDir, "preview.jpg") + + if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + servePreviewJPEGFile(w, r, jpegPath) return } img, err := extractLastFrameJPEG(outPath) if err != nil { - // Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger img2, err2 := extractFirstFrameJPEG(outPath) if err2 != nil { http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError) @@ -405,13 +645,25 @@ func recordPreview(w http.ResponseWriter, r *http.Request) { img = img2 } + _ = os.WriteFile(jpegPath, img, 0o644) + servePreviewJPEGBytes(w, img) +} + +func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) { w.Header().Set("Content-Type", "image/jpeg") - w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Cache-Control", "public, max-age=31536000") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(img) } +func servePreviewJPEGFile(w http.ResponseWriter, r *http.Request, path string) { + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=31536000") + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeFile(w, r, path) +} + func recordList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) @@ -506,6 +758,10 @@ func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string { } func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie, userAgent string) error { + if strings.TrimSpace(ffmpegPath) == "" { + return fmt.Errorf("kein ffmpeg gefunden – setze FFMPEG_PATH oder lege ffmpeg(.exe) neben das Backend") + } + if err := os.MkdirAll(previewDir, 0755); err != nil { return err } @@ -553,20 +809,20 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie // beide Prozesse starten (einfach & robust) go func(kind string, args []string) { - cmd := exec.CommandContext(ctx, "ffmpeg", args...) + cmd := exec.CommandContext(ctx, ffmpegPath, args...) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil && ctx.Err() == nil { - fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) + fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) } }("low", lowArgs) go func(kind string, args []string) { - cmd := exec.CommandContext(ctx, "ffmpeg", args...) + cmd := exec.CommandContext(ctx, ffmpegPath, args...) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil && ctx.Err() == nil { - fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) + fmt.Printf("⚠️ preview %s ffmpeg failed: %v (%s)\n", kind, err, strings.TrimSpace(stderr.String())) } }("hq", hqArgs) @@ -575,7 +831,7 @@ func startPreviewHLS(ctx context.Context, jobID, m3u8URL, previewDir, httpCookie func extractFirstFrameJPEG(path string) ([]byte, error) { cmd := exec.Command( - "ffmpeg", + ffmpegPath, "-hide_banner", "-loglevel", "error", "-i", path, @@ -930,8 +1186,24 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { 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([]*RecordJob{}) + return + } + entries, err := os.ReadDir(doneAbs) if err != nil { + // Wenn Verzeichnis nicht existiert → leere Liste statt 500 + if os.IsNotExist(err) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode([]*RecordJob{}) + return + } + http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } @@ -953,10 +1225,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { continue } - // ID stabil aus Dateiname (ohne Extension) – reicht für Player/Key base := strings.TrimSuffix(name, filepath.Ext(name)) - - // best effort: Zeiten aus FileInfo t := fi.ModTime() list = append(list, &RecordJob{ @@ -969,7 +1238,6 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { }) } - // neueste zuerst sort.Slice(list, func(i, j int) bool { return list[i].EndedAt.After(*list[j].EndedAt) }) @@ -1398,7 +1666,7 @@ func (p *Playlist) WatchSegments( ) error { var lastSeq int64 = -1 emptyRounds := 0 - const maxEmptyRounds = 5 + const maxEmptyRounds = 60 // statt 5 for { select { @@ -1741,7 +2009,7 @@ func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error { // ffmpeg mit Context (STOP FUNKTIONIERT HIER!) cmd := exec.CommandContext( ctx, - "ffmpeg", + ffmpegPath, "-y", "-i", m3u8URL, "-c", "copy", diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 06fca4c..aaed66a 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,4 +1,7 @@ { - "recordDir": "C:\\Users\\Rother\\Desktop\\test", - "doneDir": "C:\\Users\\Rother\\Desktop\\test\\done" + "recordDir": "C:\\test", + "doneDir": "C:\\test\\done", + "ffmpegPath": "C:\\ffmpeg\\ffmpeg.exe", + "autoAddToDownloadList": true, + "autoStartAddedDownloads": true } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfe46d5..f2ee76e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import './App.css' import Button from './components/ui/Button' import Table, { type Column } from './components/ui/Table' @@ -64,6 +64,35 @@ const runtimeOf = (j: RecordJob) => { return formatDuration(end - start) } +type RecorderSettings = { + recordDir: string + doneDir: string + ffmpegPath?: string + autoAddToDownloadList?: boolean + autoStartAddedDownloads?: boolean +} + +const DEFAULT_RECORDER_SETTINGS: RecorderSettings = { + recordDir: 'records', + doneDir: 'records/done', + ffmpegPath: '', + autoAddToDownloadList: false, + autoStartAddedDownloads: false, +} + +function extractFirstHttpUrl(text: string): string | null { + const t = (text ?? '').trim() + if (!t) return null + + // erstes Token, das wie eine URL aussieht + for (const token of t.split(/\s+/g)) { + if (!/^https?:\/\//i.test(token)) continue + try { + return new URL(token).toString() + } catch {} + } + return null +} export default function App() { const [sourceUrl, setSourceUrl] = useState('') @@ -80,6 +109,46 @@ export default function App() { const [playerJob, setPlayerJob] = useState(null) const [playerExpanded, setPlayerExpanded] = useState(false) + const [recSettings, setRecSettings] = useState(DEFAULT_RECORDER_SETTINGS) + + const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList) + const autoStartEnabled = Boolean(recSettings.autoStartAddedDownloads) + + // "latest" Refs (damit Clipboard-Loop nicht wegen jobs-Polling neu startet) + const busyRef = useRef(false) + const cookiesRef = useRef>({}) + const jobsRef = useRef([]) + + useEffect(() => { busyRef.current = busy }, [busy]) + useEffect(() => { cookiesRef.current = cookies }, [cookies]) + useEffect(() => { jobsRef.current = jobs }, [jobs]) + + // pending start falls gerade busy + const pendingStartUrlRef = useRef(null) + // um identische Clipboard-Werte nicht dauernd zu triggern + const lastClipboardUrlRef = useRef('') + + // settings poll (damit Umschalten im Settings-Tab ohne Reload wirkt) + useEffect(() => { + let cancelled = false + + const load = async () => { + try { + const s = await apiJSON('/api/settings', { cache: 'no-store' }) + if (!cancelled && s) setRecSettings((prev) => ({ ...prev, ...s })) + } catch { + // ignore + } + } + + load() + const t = window.setInterval(load, 3000) + return () => { + cancelled = true + window.clearInterval(t) + } + }, []) + const initialCookies = useMemo( () => Object.entries(cookies).map(([name, value]) => ({ name, value })), [cookies] @@ -146,31 +215,31 @@ export default function App() { }, [sourceUrl]) useEffect(() => { - const interval = setInterval(() => { - setJobs((prev) => { - prev.forEach((job) => { - if (job.status !== 'running') return - apiJSON(`/api/record/status?id=${encodeURIComponent(job.id)}`) - .then((updated) => { - setJobs((curr) => - curr.map((j) => (j.id === updated.id ? updated : j)) - ) - }) - .catch(() => {}) - }) - return prev - }) - }, 1000) + let cancelled = false - return () => clearInterval(interval) - }, []) + const loadJobs = async () => { + try { + const list = await apiJSON('/api/record/list') + if (!cancelled) { + setJobs(Array.isArray(list) ? list : []) + } + } catch { + if (!cancelled) { + // optional: bei Fehler nicht alles leeren, sondern Zustand behalten + // setJobs([]) + } + } + } - useEffect(() => { - apiJSON('/api/record/list') - .then((list) => setJobs(list)) - .catch(() => { - // backend evtl. noch nicht da -> ignorieren - }) + // direkt einmal laden + loadJobs() + // dann jede Sekunde + const t = setInterval(loadJobs, 1000) + + return () => { + cancelled = true + clearInterval(t) + } }, []) useEffect(() => { @@ -216,33 +285,38 @@ export default function App() { return Boolean(cf && sess) } - async function onStart() { + const startUrl = useCallback(async (rawUrl: string) => { + const url = rawUrl.trim() + if (!url) return + if (busyRef.current) return + setError(null) - const url = sourceUrl.trim() - // ❌ Chaturbate ohne Cookies blockieren - if (isChaturbate(url) && !hasRequiredChaturbateCookies(cookies)) { - setError( - 'Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.' - ) + const currentCookies = cookiesRef.current + if (isChaturbate(url) && !hasRequiredChaturbateCookies(currentCookies)) { + setError('Für Chaturbate müssen die Cookies "cf_clearance" und "sessionId" gesetzt sein.') return } + // Duplicate-running guard + const alreadyRunning = jobsRef.current.some( + (j) => j.status === 'running' && String(j.sourceUrl || '') === url + ) + if (alreadyRunning) return + setBusy(true) + busyRef.current = true try { - const cookieString = Object.entries(cookies) + const cookieString = Object.entries(currentCookies) .map(([k, v]) => `${k}=${v}`) .join('; ') const created = await apiJSON('/api/record', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url, - cookie: cookieString, - }), + body: JSON.stringify({ url, cookie: cookieString }), }) setJobs((prev) => [created, ...prev]) @@ -250,9 +324,84 @@ export default function App() { setError(e?.message ?? String(e)) } finally { setBusy(false) + busyRef.current = false } + }, []) // arbeitet über refs, daher keine deps nötig + + async function onStart() { + return startUrl(sourceUrl) } + useEffect(() => { + if (!autoAddEnabled && !autoStartEnabled) return + if (!navigator.clipboard?.readText) return + + let cancelled = false + let inFlight = false + let timer: number | null = null + + const checkClipboard = async () => { + if (cancelled || inFlight) return + inFlight = true + try { + const text = await navigator.clipboard.readText() + const url = extractFirstHttpUrl(text) + if (!url) return + + if (url === lastClipboardUrlRef.current) return + lastClipboardUrlRef.current = url + + // Auto-Add: Input befüllen + if (autoAddEnabled) setSourceUrl(url) + + // Auto-Start: sofort starten oder merken, wenn busy + if (autoStartEnabled) { + if (busyRef.current) { + pendingStartUrlRef.current = url + } else { + pendingStartUrlRef.current = null + await startUrl(url) + } + } + } catch { + // In Browsern kann readText im Hintergrund/ohne Permission failen -> ignorieren + } finally { + inFlight = false + } + } + + const schedule = (ms: number) => { + if (cancelled) return + timer = window.setTimeout(async () => { + await checkClipboard() + // Hintergrund weniger aggressiv pollen + schedule(document.hidden ? 5000 : 1500) + }, ms) + } + + const kick = () => void checkClipboard() + window.addEventListener('focus', kick) + document.addEventListener('visibilitychange', kick) + + schedule(0) + + return () => { + cancelled = true + if (timer) window.clearTimeout(timer) + window.removeEventListener('focus', kick) + document.removeEventListener('visibilitychange', kick) + } + }, [autoAddEnabled, autoStartEnabled, startUrl]) + + useEffect(() => { + if (busy) return + if (!autoStartEnabled) return + const pending = pendingStartUrlRef.current + if (!pending) return + pendingStartUrlRef.current = null + void startUrl(pending) + }, [busy, autoStartEnabled, startUrl]) + async function stopJob(id: string) { try { await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { @@ -261,65 +410,6 @@ export default function App() { } catch {} } - const columns: Column[] = [ - { - key: 'preview', - header: 'Vorschau', - cell: (j) => - j.status === 'running' - ? - : - }, - { - key: 'model', - header: 'Modelname', - cell: (j) => ( - - {modelNameFromOutput(j.output)} - - ), - }, - { - key: 'sourceUrl', - header: 'Source', - cell: (j) => ( - - {j.sourceUrl} - - ), - }, - { - key: 'output', - header: 'Datei', - cell: (j) => baseName(j.output || ''), - }, - { key: 'status', header: 'Status' }, - { - key: 'runtime', - header: 'Dauer', - cell: (j) => runtimeOf(j), - }, - { - key: 'actions', - header: 'Aktion', - srOnlyHeader: true, - align: 'right', - cell: (j) => - j.status === 'running' ? ( - - ) : ( - - ), - }, - ] - return (
diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 455d252..5d9e26f 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -1,4 +1,4 @@ -// FinishedDownloads.tsx +// frontend/src/components/ui/FinishedDownloads.tsx 'use client' import * as React from 'react' @@ -35,7 +35,8 @@ function formatDuration(ms: number): string { return `${s}s` } -function runtimeOf(job: RecordJob): string { +// Fallback: reine Aufnahmezeit aus startedAt/endedAt +function runtimeFromTimestamps(job: RecordJob): string { const start = Date.parse(String(job.startedAt || '')) const end = Date.parse(String(job.endedAt || '')) if (!Number.isFinite(start) || !Number.isFinite(end)) return '—' @@ -60,6 +61,20 @@ const modelNameFromOutput = (output?: string) => { export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Props) { const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null) + // 🔄 globaler Tick für animierte Thumbnails der fertigen Videos + const [thumbTick, setThumbTick] = React.useState(0) + + React.useEffect(() => { + const id = window.setInterval(() => { + setThumbTick((t) => t + 1) + }, 3000) // alle 3 Sekunden + + return () => window.clearInterval(id) + }, []) + + // 🔹 hier sammeln wir die Videodauer pro Job/Datei (Sekunden) + const [durations, setDurations] = React.useState>({}) + const openCtx = (job: RecordJob, e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -106,8 +121,10 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop const rows = useMemo(() => { const map = new Map() + // Basis: Files aus dem Done-Ordner for (const j of doneJobs) map.set(keyFor(j), j) + // Jobs aus /list drübermergen (z.B. frisch fertiggewordene) for (const j of jobs) { const k = keyFor(j) if (map.has(k)) map.set(k, { ...map.get(k)!, ...j }) @@ -121,11 +138,42 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop return list }, [jobs, doneJobs]) + // 🧠 Laufzeit-Anzeige: bevorzugt Videodauer, sonst Fallback auf startedAt/endedAt + const runtimeOf = (job: RecordJob): string => { + const k = keyFor(job) + const sec = durations[k] + if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) { + return formatDuration(sec * 1000) + } + return runtimeFromTimestamps(job) + } + + // Wird von FinishedVideoPreview aufgerufen, sobald die Metadaten da sind + const handleDuration = React.useCallback((job: RecordJob, seconds: number) => { + if (!Number.isFinite(seconds) || seconds <= 0) return + const k = keyFor(job) + setDurations((prev) => { + const old = prev[k] + if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) { + return prev // keine unnötigen Re-Renders + } + return { ...prev, [k]: seconds } + }) + }, []) + const columns: Column[] = [ { key: 'preview', header: 'Vorschau', - cell: (j) => , + cell: (j) => ( + + ), }, { key: 'model', @@ -252,7 +300,12 @@ export default function FinishedDownloads({ jobs, doneJobs, onOpenPlayer }: Prop openCtx(j, e) }} > - +
diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index a84364c..a525947 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -1,55 +1,148 @@ // frontend/src/components/ui/FinishedVideoPreview.tsx 'use client' +import { useMemo, useState, type SyntheticEvent } from 'react' import type { RecordJob } from '../../types' import HoverPopover from './HoverPopover' type Props = { job: RecordJob getFileName: (path: string) => string + // 🔹 optional: bereits bekannte Dauer (Sekunden) + durationSeconds?: number + // 🔹 Callback nach oben, wenn wir die Dauer ermittelt haben + onDuration?: (job: RecordJob, seconds: number) => void + + thumbTick?: number } -export default function FinishedVideoPreview({ job, getFileName }: Props) { +export default function FinishedVideoPreview({ + job, + getFileName, + durationSeconds, + onDuration, + thumbTick +}: Props) { const file = getFileName(job.output || '') - const src = file ? `/api/record/video?file=${encodeURIComponent(file)}` : '' - if (!src) { - return
+ const [thumbOk, setThumbOk] = useState(true) + const [metaLoaded, setMetaLoaded] = useState(false) + + // id für /api/record/preview: Dateiname ohne Extension + const previewId = useMemo(() => { + if (!file) return '' + const dot = file.lastIndexOf('.') + return dot > 0 ? file.slice(0, dot) : file + }, [file]) + + const videoSrc = useMemo( + () => (file ? `/api/record/video?file=${encodeURIComponent(file)}` : ''), + [file] + ) + + const hasDuration = + typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 + + const tick = thumbTick ?? 0 + + // Zeitposition im Video: alle 3s ein Schritt, modulo Videolänge + const thumbTimeSec = useMemo(() => { + if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + // Keine Dauer bekannt → einfach bei 0s (erster Frame) bleiben + return 0 + } + const step = 3 // Sekunden pro Schritt + const steps = Math.max(0, Math.floor(tick)) + // kleine Reserve, damit wir nicht exakt auf das letzte Frame springen + const total = Math.max(durationSeconds - 0.1, step) + return (steps * step) % total + }, [durationSeconds, tick]) + + // Thumbnail (immer mit t=..., auch wenn t=0 → erster Frame) + const thumbSrc = useMemo(() => { + if (!previewId) return '' + + const params: string[] = [] + + // ⬅️ immer Zeitposition mitgeben, auch bei 0 + params.push(`t=${encodeURIComponent(thumbTimeSec.toFixed(2))}`) + + // Versionierung für den Browser-Cache / Animation + if (typeof thumbTick === 'number') { + params.push(`v=${encodeURIComponent(String(thumbTick))}`) + } + + const qs = params.length ? `&${params.join('&')}` : '' + return `/api/record/preview?id=${encodeURIComponent(previewId)}${qs}` + }, [previewId, thumbTimeSec, thumbTick]) + + const handleLoadedMetadata = (e: SyntheticEvent) => { + setMetaLoaded(true) + if (!onDuration) return + + const secs = e.currentTarget.duration + if (Number.isFinite(secs) && secs > 0) { + onDuration(job, secs) + } + } + + if (!videoSrc) { + return ( +
+ ) } return ( -
-
+ ) } > - {/* Mini in Tabelle */} -
+ + {/* ffmpeg.exe */} +
+ +
+ setValue((v) => ({ ...v, ffmpegPath: e.target.value }))} + placeholder="Leer = automatisch (FFMPEG_PATH / ffmpeg im PATH)" + className="min-w-0 flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 + dark:bg-white/10 dark:text-white" + /> + +
+
+ + {/* Automatisierung */} +
+
+ + setValue((v) => ({ + ...v, + autoAddToDownloadList: checked, + // wenn aus, Autostart gleich mit aus + autoStartAddedDownloads: checked ? v.autoStartAddedDownloads : false, + })) + } + label="Automatisch zur Downloadliste hinzufügen" + description="Neue Links/Modelle werden automatisch in die Downloadliste übernommen." + /> + + setValue((v) => ({ ...v, autoStartAddedDownloads: checked }))} + disabled={!value.autoAddToDownloadList} + label="Hinzugefügte Downloads automatisch starten" + description="Wenn ein Download hinzugefügt wurde, startet er direkt (sofern möglich)." + /> +
+
) diff --git a/frontend/src/components/ui/RunningDownloads.tsx b/frontend/src/components/ui/RunningDownloads.tsx index 1ed6e7f..9f2ba81 100644 --- a/frontend/src/components/ui/RunningDownloads.tsx +++ b/frontend/src/components/ui/RunningDownloads.tsx @@ -1,7 +1,7 @@ // RunningDownloads.tsx 'use client' -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import Table, { type Column } from './Table' import Card from './Card' import Button from './Button' @@ -50,12 +50,23 @@ const runtimeOf = (j: RecordJob) => { } export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Props) { + // globaler Tick für alle Thumbnails + const [thumbTick, setThumbTick] = useState(0) + + useEffect(() => { + const id = window.setInterval(() => { + setThumbTick((t) => t + 1) + }, 5000) // alle 5s + + return () => window.clearInterval(id) + }, []) + const columns = useMemo[]>(() => { return [ { key: 'preview', header: 'Vorschau', - cell: (j) => , + cell: (j) => , }, { key: 'model', @@ -114,7 +125,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop ), }, ] - }, [onStopJob]) + }, [onStopJob, thumbTick]) if (jobs.length === 0) { return ( @@ -173,7 +184,7 @@ export default function RunningDownloads({ jobs, onOpenPlayer, onStopJob }: Prop >
e.stopPropagation()}> - +
diff --git a/frontend/src/components/ui/Switch.tsx b/frontend/src/components/ui/Switch.tsx new file mode 100644 index 0000000..3ebfcaa --- /dev/null +++ b/frontend/src/components/ui/Switch.tsx @@ -0,0 +1,174 @@ +// components/ui/Switch.tsx +'use client' + +import * as React from 'react' +import clsx from 'clsx' + +type SwitchSize = 'default' | 'short' +type SwitchVariant = 'simple' | 'icon' + +export type SwitchProps = { + /** Controlled */ + checked: boolean + onChange: (checked: boolean) => void + + /** Optional wiring */ + id?: string + name?: string + disabled?: boolean + required?: boolean + + /** Labeling / a11y */ + ariaLabel?: string + ariaLabelledby?: string + ariaDescribedby?: string + + /** UI */ + size?: SwitchSize + variant?: SwitchVariant + className?: string +} + +/** + * Switch / Toggle (Tailwind, ohne Headless UI) + * - size="default" (w-11) wie Simple toggle + * - size="short" (h-5 w-10) wie Short toggle + * - variant="icon" zeigt X/Check Icon im Thumb + */ +export default function Switch({ + checked, + onChange, + id, + name, + disabled, + required, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + size = 'default', + variant = 'simple', + className, +}: SwitchProps) { + const handleChange = (e: React.ChangeEvent) => { + if (disabled) return + onChange(e.target.checked) + } + + const baseInput = clsx( + 'absolute inset-0 size-full appearance-none focus:outline-hidden', + disabled && 'cursor-not-allowed' + ) + + if (size === 'short') { + // Short toggle Beispiel + return ( +
+ + + +
+ ) + } + + // Default size (simple / icon) Beispiele + return ( +
+ {variant === 'icon' ? ( + + {/* Off icon */} + + + {/* On icon */} + + + ) : ( + + )} + + +
+ ) +} diff --git a/frontend/src/components/ui/Tabs.tsx b/frontend/src/components/ui/Tabs.tsx index 0516464..8d5d747 100644 --- a/frontend/src/components/ui/Tabs.tsx +++ b/frontend/src/components/ui/Tabs.tsx @@ -1,20 +1,44 @@ 'use client' +import * as React from 'react' import { ChevronDownIcon } from '@heroicons/react/16/solid' import clsx from 'clsx' +export type TabIcon = React.ComponentType> + export type TabItem = { id: string label: string - count?: number + count?: number | string + icon?: TabIcon + disabled?: boolean } +export type TabsVariant = + | 'underline' + | 'underlineIcons' + | 'pills' + | 'pillsGray' + | 'pillsBrand' + | 'fullWidthUnderline' + | 'barUnderline' + | 'simple' + type TabsProps = { tabs: TabItem[] value: string onChange: (id: string) => void className?: string ariaLabel?: string + + /** Siehe Variants aus pasted.txt */ + variant?: TabsVariant + + /** + * Optional: In der pasted.txt sind Badges teils erst ab md sichtbar. + * Default: false (wie deine bisherige Komponente: immer sichtbar auf Desktop). + */ + hideCountUntilMd?: boolean } export default function Tabs({ @@ -23,19 +47,258 @@ export default function Tabs({ onChange, className, ariaLabel = 'Ansicht auswählen', + variant = 'underline', + hideCountUntilMd = false, }: TabsProps) { + if (!tabs?.length) return null + const current = tabs.find((t) => t.id === value) ?? tabs[0] + const mobileSelectClass = clsx( + // entspricht den Beispielen (outline-1 + -outline-offset-1) + 'col-start-1 row-start-1 w-full appearance-none rounded-md bg-white py-2 pr-8 pl-3 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 dark:text-gray-100 dark:outline-white/10 dark:*:bg-gray-800 dark:focus:outline-indigo-500', + variant === 'pillsBrand' ? 'dark:bg-gray-800/50' : 'dark:bg-white/5' + ) + + const countPillClass = (selected: boolean) => + clsx( + selected + ? 'bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400' + : 'bg-gray-100 text-gray-900 dark:bg-white/10 dark:text-gray-300', + hideCountUntilMd ? 'ml-3 hidden rounded-full px-2.5 py-0.5 text-xs font-medium md:inline-block' : 'ml-3 rounded-full px-2.5 py-0.5 text-xs font-medium' + ) + + const renderCount = (selected: boolean, tab: TabItem) => { + if (tab.count === undefined) return null + return {tab.count} + } + + const renderDesktop = () => { + switch (variant) { + case 'underline': + case 'underlineIcons': { + return ( +
+ +
+ ) + } + + case 'pills': + case 'pillsGray': + case 'pillsBrand': { + const active = + variant === 'pills' + ? 'bg-gray-100 text-gray-700 dark:bg-white/10 dark:text-gray-200' + : variant === 'pillsGray' + ? 'bg-gray-200 text-gray-800 dark:bg-white/10 dark:text-white' + : 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300' + + const inactive = + variant === 'pills' + ? 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' + : variant === 'pillsGray' + ? 'text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white' + : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200' + + return ( + + ) + } + + case 'fullWidthUnderline': { + return ( +
+ +
+ ) + } + + case 'barUnderline': { + return ( + + ) + } + + case 'simple': { + return ( + + ) + } + + default: + return null + } + } + return (
- {/* Mobile: Dropdown */} + {/* Mobile: Select + Chevron (wie Beispiele) */}
- onChange(e.target.value)} aria-label={ariaLabel} className={mobileSelectClass}> {tabs.map((tab) => (
- {/* Desktop: Horizontal Tabs */} -
- -
+ {/* Desktop */} +
{renderDesktop()}
) }