nsfwapp/backend/main.go
2026-01-02 13:13:03 +01:00

4192 lines
105 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

package main
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/google/uuid"
"github.com/grafov/m3u8"
"github.com/sqweek/dialog"
)
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
type JobStatus string
const (
JobRunning JobStatus = "running"
JobFinished JobStatus = "finished"
JobFailed JobStatus = "failed"
JobStopped JobStatus = "stopped"
)
type RecordJob struct {
ID string `json:"id"`
SourceURL string `json:"sourceUrl"`
Output string `json:"output"`
Status JobStatus `json:"status"`
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
PreviewImage string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
// Thumbnail cache (verhindert, dass pro HTTP-Request ffmpeg läuft)
previewMu sync.Mutex `json:"-"`
previewJpeg []byte `json:"-"`
previewJpegAt time.Time `json:"-"`
previewGen bool `json:"-"`
// ✅ Frontend Progress beim Stop/Finalize
Phase string `json:"phase,omitempty"` // stopping | remuxing | moving | finalizing
Progress int `json:"progress,omitempty"` // 0..100
cancel context.CancelFunc `json:"-"`
}
type dummyResponseWriter struct {
h http.Header
}
func (d *dummyResponseWriter) Header() http.Header {
if d.h == nil {
d.h = make(http.Header)
}
return d.h
}
func (d *dummyResponseWriter) Write(b []byte) (int, error) { return len(b), nil }
func (d *dummyResponseWriter) WriteHeader(statusCode int) {}
var (
jobs = map[string]*RecordJob{}
jobsMu = sync.Mutex{}
)
// -------------------- SSE: /api/record/stream --------------------
type sseHub struct {
mu sync.Mutex
clients map[chan []byte]struct{}
}
func newSSEHub() *sseHub {
return &sseHub{clients: map[chan []byte]struct{}{}}
}
func (h *sseHub) add(ch chan []byte) {
h.mu.Lock()
h.clients[ch] = struct{}{}
h.mu.Unlock()
}
func (h *sseHub) remove(ch chan []byte) {
h.mu.Lock()
delete(h.clients, ch)
h.mu.Unlock()
close(ch)
}
func (h *sseHub) broadcast(b []byte) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
// Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein)
select {
case ch <- b:
default:
}
}
}
var recordJobsHub = newSSEHub()
var recordJobsNotify = make(chan struct{}, 1)
func init() {
// Debounced broadcaster
go func() {
for range recordJobsNotify {
// kleine Debounce-Phase, um Burst-Updates zusammenzufassen
time.Sleep(40 * time.Millisecond)
// Kanal drainen (falls mehrere Notifies in kurzer Zeit kamen)
for {
select {
case <-recordJobsNotify:
default:
goto SEND
}
}
SEND:
recordJobsHub.broadcast(jobsSnapshotJSON())
}
}()
}
func notifyJobsChanged() {
select {
case recordJobsNotify <- struct{}{}:
default:
}
}
func jobsSnapshotJSON() []byte {
jobsMu.Lock()
list := make([]RecordJob, 0, len(jobs))
for _, j := range jobs {
if j == nil {
continue
}
c := *j // copy => stabiler Snapshot fürs JSON
list = append(list, c)
}
jobsMu.Unlock()
sort.Slice(list, func(i, j int) bool {
return list[i].StartedAt.After(list[j].StartedAt)
})
b, _ := json.Marshal(list)
return b
}
func recordStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // hilfreich bei Reverse-Proxies
// Reconnect-Hinweis für Browser
_, _ = fmt.Fprintf(w, "retry: 3000\n\n")
flusher.Flush()
ch := make(chan []byte, 16)
recordJobsHub.add(ch)
defer recordJobsHub.remove(ch)
// Initialer Snapshot sofort
if b := jobsSnapshotJSON(); len(b) > 0 {
_, _ = fmt.Fprintf(w, "event: jobs\ndata: %s\n\n", b)
flusher.Flush()
}
ping := time.NewTicker(15 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case b := <-ch:
if len(b) == 0 {
continue
}
_, _ = fmt.Fprintf(w, "event: jobs\ndata: %s\n\n", b)
flusher.Flush()
case <-ping.C:
// Keepalive als Kommentar (stört nicht, hält Verbindungen offen)
_, _ = fmt.Fprintf(w, ": ping %d\n\n", time.Now().Unix())
flusher.Flush()
}
}
}
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
var ffmpegPath = detectFFmpegPath()
var ffprobePath = detectFFprobePath()
func detectFFprobePath() string {
// 1) Env-Override
if p := strings.TrimSpace(os.Getenv("FFPROBE_PATH")); p != "" {
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
// 2) Neben ffmpeg.exe (gleicher Ordner)
fp := strings.TrimSpace(ffmpegPath)
if fp != "" && fp != "ffmpeg" {
dir := filepath.Dir(fp)
ext := ""
if strings.HasSuffix(strings.ToLower(fp), ".exe") {
ext = ".exe"
}
c := filepath.Join(dir, "ffprobe"+ext)
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
return c
}
}
// 3) Im EXE-Ordner
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, "ffprobe"),
filepath.Join(exeDir, "ffprobe.exe"),
}
for _, c := range candidates {
if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
return c
}
}
}
// 4) PATH
if lp, err := exec.LookPath("ffprobe"); err == nil {
if abs, err2 := filepath.Abs(lp); err2 == nil {
return abs
}
return lp
}
return "ffprobe"
}
// Preview/Teaser-Generierung nicht unendlich parallel
var genSem = make(chan struct{}, 2)
type durEntry struct {
size int64
mod time.Time
sec float64
}
var durCache = struct {
mu sync.Mutex
m map[string]durEntry
}{m: map[string]durEntry{}}
var startedAtFromFilenameRe = regexp.MustCompile(
`^(.+)_([0-9]{1,2})_([0-9]{1,2})_([0-9]{4})__([0-9]{1,2})-([0-9]{2})-([0-9]{2})$`,
)
func setJobPhase(job *RecordJob, phase string, progress int) {
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
jobsMu.Lock()
job.Phase = phase
job.Progress = progress
jobsMu.Unlock()
}
func durationSecondsCached(ctx context.Context, path string) (float64, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
}
durCache.mu.Lock()
if e, ok := durCache.m[path]; ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
durCache.mu.Unlock()
return e.sec, nil
}
durCache.mu.Unlock()
// 1) ffprobe (bevorzugt)
cmd := exec.CommandContext(ctx, ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
path,
)
out, err := cmd.Output()
if err == nil {
s := strings.TrimSpace(string(out))
sec, err2 := strconv.ParseFloat(s, 64)
if err2 == nil && sec > 0 {
durCache.mu.Lock()
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
durCache.mu.Unlock()
return sec, nil
}
}
// 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen
cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path)
b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da
text := string(b)
re := regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
m := re.FindStringSubmatch(text)
if len(m) != 4 {
return 0, fmt.Errorf("duration not found")
}
hh, _ := strconv.ParseFloat(m[1], 64)
mm, _ := strconv.ParseFloat(m[2], 64)
ss, _ := strconv.ParseFloat(m[3], 64)
sec := hh*3600 + mm*60 + ss
if sec <= 0 {
return 0, fmt.Errorf("invalid duration")
}
durCache.mu.Lock()
durCache.m[path] = durEntry{size: fi.Size(), mod: fi.ModTime(), sec: sec}
durCache.mu.Unlock()
return sec, nil
}
// main.go
type RecorderSettings struct {
RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList,omitempty"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads,omitempty"`
UseChaturbateAPI bool `json:"useChaturbateApi,omitempty"`
BlurPreviews bool `json:"blurPreviews,omitempty"`
// EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map.
EncryptedCookies string `json:"encryptedCookies,omitempty"`
}
var (
settingsMu sync.Mutex
settings = RecorderSettings{
RecordDir: "/records",
DoneDir: "/records/done",
FFmpegPath: "",
AutoAddToDownloadList: false,
AutoStartAddedDownloads: false,
UseChaturbateAPI: false,
BlurPreviews: false,
EncryptedCookies: "",
}
settingsFile = "recorder_settings.json"
)
func getSettings() RecorderSettings {
settingsMu.Lock()
defer settingsMu.Unlock()
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 := resolvePathRelativeToApp(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 removeGeneratedForID(id string) {
thumbsRoot, _ := generatedThumbsRoot()
teaserRoot, _ := generatedTeaserRoot()
_ = os.RemoveAll(filepath.Join(thumbsRoot, id))
_ = os.Remove(filepath.Join(teaserRoot, id+".mp4"))
}
func renameGenerated(oldID, newID string) {
thumbsRoot, _ := generatedThumbsRoot()
teaserRoot, _ := generatedTeaserRoot()
oldThumb := filepath.Join(thumbsRoot, oldID)
newThumb := filepath.Join(thumbsRoot, newID)
if _, err := os.Stat(oldThumb); err == nil {
if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) {
_ = os.Rename(oldThumb, newThumb)
} else {
_ = os.RemoveAll(oldThumb)
}
}
oldTeaser := filepath.Join(teaserRoot, oldID+".mp4")
newTeaser := filepath.Join(teaserRoot, newID+".mp4")
if _, err := os.Stat(oldTeaser); err == nil {
if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) {
_ = os.Rename(oldTeaser, newTeaser)
} else {
_ = os.Remove(oldTeaser)
}
}
}
func loadSettings() {
b, err := os.ReadFile(settingsFile)
if err == nil {
var s RecorderSettings
if json.Unmarshal(b, &s) == nil {
if strings.TrimSpace(s.RecordDir) != "" {
s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir))
}
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()
}
}
// Ordner sicherstellen
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)
ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath)
}
func saveSettingsToDisk() {
s := getSettings()
b, _ := json.MarshalIndent(s, "", " ")
_ = os.WriteFile(settingsFile, b, 0o644)
}
func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(getSettings())
return
case http.MethodPost:
var in RecorderSettings
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
}
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)
return
}
// Ordner erstellen (Fehler zurückgeben, falls z.B. keine Rechte)
if err := os.MkdirAll(in.RecordDir, 0o755); err != nil {
http.Error(w, "konnte recordDir nicht erstellen: "+err.Error(), http.StatusBadRequest)
return
}
if err := os.MkdirAll(in.DoneDir, 0o755); err != nil {
http.Error(w, "konnte doneDir nicht erstellen: "+err.Error(), http.StatusBadRequest)
return
}
current := getSettings()
in.EncryptedCookies = current.EncryptedCookies
settingsMu.Lock()
settings = in
settingsMu.Unlock()
saveSettingsToDisk()
// ffmpeg-Pfad nach Änderungen neu bestimmen
ffmpegPath = detectFFmpegPath()
fmt.Println("🔍 ffmpegPath:", ffmpegPath)
ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath:", ffprobePath)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(getSettings())
return
default:
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
return
}
}
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
if target != "record" && target != "done" && target != "ffmpeg" {
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
return
}
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, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
// optional: wenn innerhalb exe-dir, als RELATIV zurückgeben
p = maybeMakeRelativeToExe(p)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"path": p})
}
func maybeMakeRelativeToExe(abs string) string {
exe, err := os.Executable()
if err != nil {
return abs
}
base := filepath.Dir(exe)
rel, err := filepath.Rel(base, abs)
if err != nil {
return abs
}
// wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return abs
}
return filepath.ToSlash(rel) // frontend-freundlich
}
// --- Gemeinsame Status-Werte für MFC ---
type Status int
const (
StatusUnknown Status = iota
StatusPublic
StatusPrivate
StatusOffline
StatusNotExist
)
func (s Status) String() string {
switch s {
case StatusPublic:
return "PUBLIC"
case StatusPrivate:
return "PRIVATE"
case StatusOffline:
return "OFFLINE"
case StatusNotExist:
return "NOTEXIST"
default:
return "UNKNOWN"
}
}
// HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR)
type HTTPClient struct {
client *http.Client
userAgent string
}
// gemeinsamen HTTP-Client erzeugen
func NewHTTPClient(userAgent string) *HTTPClient {
if userAgent == "" {
// Default, falls kein UA übergeben wird
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
return &HTTPClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
userAgent: userAgent,
}
}
// Request-Erstellung mit User-Agent + Cookies
func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, err
}
// Basis-Header, die immer gesetzt werden
if h.userAgent != "" {
req.Header.Set("User-Agent", h.userAgent)
} else {
req.Header.Set("User-Agent", "Mozilla/5.0")
}
req.Header.Set("Accept", "*/*")
// Cookie-String wie "name=value; foo=bar"
addCookiesFromString(req, cookieStr)
return req, nil
}
// Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate)
func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) {
req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr)
if err != nil {
return "", err
}
resp, err := h.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
body := string(data)
// Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden"
if strings.Contains(body, "<title>Just a moment...</title>") {
return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") kein Room-HTML")
}
if strings.Contains(body, "Verify your age") {
return "", errors.New("Altersverifikationsseite erhalten kein Room-HTML")
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
}
return body, nil
}
func remuxTSToMP4(tsPath, mp4Path string) error {
// ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4
cmd := exec.Command(ffmpegPath,
"-y",
"-i", tsPath,
"-c", "copy",
"-movflags", "+faststart",
mp4Path,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String())
}
return nil
}
// --- MP4 Streaming Optimierung (Fast Start) ---
// "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen)
func isFastStartMP4(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
for i := 0; i < 256; i++ {
var hdr [8]byte
if _, err := io.ReadFull(f, hdr[:]); err != nil {
// unklar/kurz -> nicht anfassen
return true, nil
}
sz32 := binary.BigEndian.Uint32(hdr[0:4])
typ := string(hdr[4:8])
var boxSize int64
headerSize := int64(8)
if sz32 == 0 {
return true, nil
}
if sz32 == 1 {
var ext [8]byte
if _, err := io.ReadFull(f, ext[:]); err != nil {
return true, nil
}
boxSize = int64(binary.BigEndian.Uint64(ext[:]))
headerSize = 16
} else {
boxSize = int64(sz32)
}
if boxSize < headerSize {
return true, nil
}
switch typ {
case "moov":
return true, nil
case "mdat":
return false, nil
}
if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil {
return true, nil
}
}
return true, nil
}
func ensureFastStartMP4(path string) error {
path = strings.TrimSpace(path)
if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") {
return nil
}
if strings.TrimSpace(ffmpegPath) == "" {
return nil
}
ok, err := isFastStartMP4(path)
if err == nil && ok {
return nil
}
dir := filepath.Dir(path)
base := filepath.Base(path)
tmp := filepath.Join(dir, ".__faststart__"+base+".tmp")
bak := filepath.Join(dir, ".__faststart__"+base+".bak")
_ = os.Remove(tmp)
_ = os.Remove(bak)
cmd := exec.Command(ffmpegPath,
"-y",
"-i", path,
"-c", "copy",
"-movflags", "+faststart",
tmp,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
}
// atomar austauschen
if err := os.Rename(path, bak); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename original to bak failed: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Rename(bak, path)
_ = os.Remove(tmp)
return fmt.Errorf("rename tmp to original failed: %w", err)
}
_ = os.Remove(bak)
return nil
}
func extractLastFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-sseof", "-0.1",
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-q:v", "10",
"-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 last-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
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",
"-vf", "scale=720:-2",
"-q:v", "10",
"-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 stripHotPrefix(s string) string {
s = strings.TrimSpace(s)
// akzeptiere "HOT " auch case-insensitive
if len(s) >= 4 && strings.EqualFold(s[:4], "HOT ") {
return strings.TrimSpace(s[4:])
}
return s
}
func generatedRoot() (string, error) {
return resolvePathRelativeToApp("generated")
}
// Legacy (falls noch alte Assets liegen):
func generatedThumbsRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
}
func generatedTeaserRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
}
// ✅ Neu: /generated/<id>/thumbs.jpg + /generated/<id>/preview.mp4
func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id)
if err != nil {
return "", err
}
root, err := generatedRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated root ist leer")
}
return filepath.Join(root, id), nil
}
func ensureGeneratedDir(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func generatedThumbFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "thumbs.jpg"), nil
}
func generatedPreviewFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "preview.mp4"), nil
}
func ensureGeneratedDirs() error {
root, err := generatedRoot()
if err != nil {
return err
}
if strings.TrimSpace(root) == "" {
return fmt.Errorf("generated root ist leer")
}
return os.MkdirAll(root, 0o755)
}
func sanitizeID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("id fehlt")
}
if strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("ungültige id")
}
return id, nil
}
func idFromVideoPath(videoPath string) string {
name := filepath.Base(strings.TrimSpace(videoPath))
return strings.TrimSuffix(name, filepath.Ext(name))
}
func atomicWriteFile(dst string, data []byte) error {
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
_ = tmp.Chmod(0o644)
_, werr := tmp.Write(data)
cerr := tmp.Close()
if werr != nil {
_ = os.Remove(tmpName)
return werr
}
if cerr != nil {
_ = os.Remove(tmpName)
return cerr
}
return os.Rename(tmpName, dst)
}
func findFinishedFileByID(id string) (string, error) {
s := getSettings()
recordAbs, _ := resolvePathRelativeToApp(s.RecordDir)
doneAbs, _ := resolvePathRelativeToApp(s.DoneDir)
base := stripHotPrefix(strings.TrimSpace(id))
if base == "" {
return "", fmt.Errorf("not found")
}
candidates := []string{
// done
filepath.Join(doneAbs, base+".mp4"),
filepath.Join(doneAbs, "HOT "+base+".mp4"),
filepath.Join(doneAbs, base+".ts"),
filepath.Join(doneAbs, "HOT "+base+".ts"),
// record
filepath.Join(recordAbs, base+".mp4"),
filepath.Join(recordAbs, "HOT "+base+".mp4"),
filepath.Join(recordAbs, base+".ts"),
filepath.Join(recordAbs, "HOT "+base+".ts"),
}
for _, p := range candidates {
fi, err := os.Stat(p)
if err == nil && !fi.IsDir() && fi.Size() > 0 {
return p, nil
}
}
return "", fmt.Errorf("not found")
}
func recordPreview(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
// HLS-Dateien (index.m3u8, seg_*.ts) wie bisher
if file := r.URL.Query().Get("file"); file != "" {
servePreviewHLSFile(w, r, id, file)
return
}
// Schauen, ob wir einen Job mit dieser ID kennen (laufend oder gerade fertig)
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if ok {
// ✅ Thumbnail-Caching: nicht pro HTTP-Request ffmpeg starten.
job.previewMu.Lock()
cached := job.previewJpeg
cachedAt := job.previewJpegAt
fresh := len(cached) > 0 && !cachedAt.IsZero() && time.Since(cachedAt) < 10*time.Second
// Wenn nicht frisch, ggf. im Hintergrund aktualisieren (einmal gleichzeitig)
if !fresh && !job.previewGen {
job.previewGen = true
go func(j *RecordJob, jobID string) {
defer func() {
j.previewMu.Lock()
j.previewGen = false
j.previewMu.Unlock()
}()
var img []byte
var genErr error
// 1) aus Preview-Segmenten
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDir(previewDir)
}
// 2) Fallback: aus der Ausgabedatei
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameJPEG(outPath)
if genErr != nil {
img, _ = extractFirstFrameJPEG(outPath)
}
}
}
}
if len(img) > 0 {
j.previewMu.Lock()
j.previewJpeg = img
j.previewJpegAt = time.Now()
j.previewMu.Unlock()
}
}(job, id)
}
// Wir liefern entweder ein frisches Bild, oder das zuletzt gecachte.
out := cached
job.previewMu.Unlock()
if len(out) > 0 {
servePreviewJPEGBytes(w, out)
return
}
// noch kein Bild verfügbar -> 204 (Frontend zeigt Placeholder und retry)
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// 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) {
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
// ✅ Assets immer auf "basename ohne HOT" ablegen
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
// ✅ Frame-Caching für t=... (optional)
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
secI := int64(sec + 0.5)
if secI < 0 {
secI = 0
}
framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.jpg", secI))
if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, framePath)
return
}
img, err := extractFrameAtTimeJPEG(outPath, float64(secI))
if err == nil && len(img) > 0 {
_ = atomicWriteFile(framePath, img)
servePreviewJPEGBytes(w, img)
return
}
}
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
// 1) Cache hit (neu)
if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, thumbPath)
return
}
// 2) Legacy-Migration (best effort)
if thumbsLegacy, _ := generatedThumbsRoot(); strings.TrimSpace(thumbsLegacy) != "" {
candidates := []string{
filepath.Join(thumbsLegacy, assetID, "preview.jpg"),
filepath.Join(thumbsLegacy, id, "preview.jpg"),
filepath.Join(thumbsLegacy, assetID+".jpg"),
filepath.Join(thumbsLegacy, id+".jpg"),
}
for _, c := range candidates {
if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 {
if b, rerr := os.ReadFile(c); rerr == nil && len(b) > 0 {
_ = atomicWriteFile(thumbPath, b)
servePreviewJPEGBytes(w, b)
return
}
}
}
}
// 3) Neu erzeugen
genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var t float64 = 0
if dur, derr := durationSecondsCached(genCtx, outPath); derr == nil && dur > 0 {
t = dur * 0.5
}
img, err := extractFrameAtTimeJPEG(outPath, t)
if err != nil || len(img) == 0 {
img, err = extractLastFrameJPEG(outPath)
if err != nil || len(img) == 0 {
img, err = extractFirstFrameJPEG(outPath)
if err != nil || len(img) == 0 {
http.Error(w, "konnte preview nicht erzeugen", http.StatusInternalServerError)
return
}
}
}
_ = atomicWriteFile(thumbPath, img)
servePreviewJPEGBytes(w, img)
}
func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
// tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams
var ffmpegInputTol = []string{
"-fflags", "+discardcorrupt+genpts",
"-err_detect", "ignore_err",
"-max_error_rate", "1.0",
}
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
if durSec <= 0 {
durSec = 8
}
if startSec < 0 {
startSec = 0
}
// temp schreiben -> rename
tmp := outPath + ".tmp.mp4"
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,
"-ss", fmt.Sprintf("%.3f", startSec),
"-i", srcPath,
"-t", fmt.Sprintf("%.3f", durSec),
"-vf", "scale=720:-2",
"-an",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "28",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-f", "mp4",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out)))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func generatedTeaser(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
http.Error(w, "id fehlt", http.StatusBadRequest)
return
}
var err error
id, err = sanitizeID(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
outPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
assetID := stripHotPrefix(id)
if assetID == "" {
assetID = id
}
assetDir, err := ensureGeneratedDir(assetID)
if err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
previewPath := filepath.Join(assetDir, "preview.mp4")
// Cache hit (neu)
if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
// Legacy: generated/teaser/<id>_teaser.mp4 oder <id>.mp4
if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" {
cids := []string{assetID, id}
for _, cid := range cids {
candidates := []string{
filepath.Join(teaserLegacy, cid+"_teaser.mp4"),
filepath.Join(teaserLegacy, cid+".mp4"),
}
for _, c := range candidates {
if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 {
if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) {
_ = os.MkdirAll(filepath.Dir(previewPath), 0o755)
_ = os.Rename(c, previewPath)
}
if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 {
serveTeaserFile(w, r, previewPath)
return
}
serveTeaserFile(w, r, c)
return
}
}
}
}
// Neu erzeugen
genSem <- struct{}{}
defer func() { <-genSem }()
genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil {
// Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen)
if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil {
http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError)
return
}
}
serveTeaserFile(w, r, previewPath)
}
// ---------------------------
// Tasks: Missing Assets erzeugen
// ---------------------------
type AssetsTaskState struct {
Running bool `json:"running"`
Total int `json:"total"`
Done int `json:"done"`
GeneratedThumbs int `json:"generatedThumbs"`
GeneratedPreviews int `json:"generatedPreviews"`
Skipped int `json:"skipped"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
Error string `json:"error,omitempty"`
}
var assetsTaskMu sync.Mutex
var assetsTaskState AssetsTaskState
func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
assetsTaskMu.Lock()
st := assetsTaskState
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
case http.MethodPost:
assetsTaskMu.Lock()
if assetsTaskState.Running {
st := assetsTaskState
assetsTaskMu.Unlock()
writeJSON(w, http.StatusOK, st)
return
}
assetsTaskState = AssetsTaskState{
Running: true,
StartedAt: time.Now(),
}
st := assetsTaskState
assetsTaskMu.Unlock()
go runGenerateMissingAssets()
writeJSON(w, http.StatusOK, st)
return
default:
http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed)
return
}
}
func runGenerateMissingAssets() {
finishWithErr := func(err error) {
now := time.Now()
assetsTaskMu.Lock()
assetsTaskState.Running = false
assetsTaskState.FinishedAt = &now
if err != nil {
assetsTaskState.Error = err.Error()
}
assetsTaskMu.Unlock()
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err))
return
}
entries, err := os.ReadDir(doneAbs)
if err != nil {
finishWithErr(fmt.Errorf("doneDir lesen fehlgeschlagen: %v", err))
return
}
type item struct {
name string
path string
}
items := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
continue
}
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
continue
}
items = append(items, item{name: name, path: filepath.Join(doneAbs, name)})
}
assetsTaskMu.Lock()
assetsTaskState.Total = len(items)
assetsTaskState.Done = 0
assetsTaskState.GeneratedThumbs = 0
assetsTaskState.GeneratedPreviews = 0
assetsTaskState.Skipped = 0
assetsTaskState.Error = ""
assetsTaskMu.Unlock()
for i, it := range items {
base := strings.TrimSuffix(it.name, filepath.Ext(it.name))
id := stripHotPrefix(base)
if strings.TrimSpace(id) == "" {
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
assetDir, derr := ensureGeneratedDir(id)
if derr != nil {
assetsTaskMu.Lock()
assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)"
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
fmt.Println("⚠️ ensureGeneratedDir:", derr)
continue
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
previewPath := filepath.Join(assetDir, "preview.mp4")
thumbOK := func() bool {
fi, err := os.Stat(thumbPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
previewOK := func() bool {
fi, err := os.Stat(previewPath)
return err == nil && !fi.IsDir() && fi.Size() > 0
}()
if thumbOK && previewOK {
assetsTaskMu.Lock()
assetsTaskState.Skipped++
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
continue
}
if !thumbOK {
genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
var t float64 = 0
if dur, derr := durationSecondsCached(genCtx, it.path); derr == nil && dur > 0 {
t = dur * 0.5
}
cancel()
img, e1 := extractFrameAtTimeJPEG(it.path, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(it.path)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(it.path)
}
}
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedThumbs++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ thumb write:", err)
}
}
}
if !previewOK {
genSem <- struct{}{}
genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18)
cancel()
<-genSem
if err == nil {
assetsTaskMu.Lock()
assetsTaskState.GeneratedPreviews++
assetsTaskMu.Unlock()
} else {
fmt.Println("⚠️ preview clips:", err)
}
}
assetsTaskMu.Lock()
assetsTaskState.Done = i + 1
assetsTaskMu.Unlock()
}
finishWithErr(nil)
}
func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error {
if clipLenSec <= 0 {
clipLenSec = 1
}
if maxClips <= 0 {
maxClips = 18
}
// Dauer holen (einmalig; wird gecached)
dur, _ := durationSecondsCached(ctx, srcPath)
// Wenn Dauer unbekannt/zu klein: einfach ab 0 ein kurzes Stück
if !(dur > 0) || dur <= clipLenSec+0.2 {
return generateTeaserMP4(ctx, srcPath, outPath, 0, math.Min(8, math.Max(clipLenSec, dur)))
}
// Anzahl Clips ähnlich wie deine Frontend-"clips"-Logik:
// mind. 8, max. maxClips, aber nicht absurd groß
count := int(math.Floor(dur))
if count < 8 {
count = 8
}
if count > maxClips {
count = maxClips
}
span := math.Max(0.1, dur-clipLenSec)
base := math.Min(0.25, span*0.02)
starts := make([]float64, 0, count)
for i := 0; i < count; i++ {
t := (float64(i)/float64(count))*span + base
if t < 0.05 {
t = 0.05
}
if t > dur-0.05-clipLenSec {
t = math.Max(0, dur-0.05-clipLenSec)
}
starts = append(starts, t)
}
// temp schreiben -> rename (WICHTIG: temp endet auf .mp4, sonst Muxer-Error)
tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4"
args := []string{
"-y",
"-hide_banner",
"-loglevel", "error",
}
// Mehrere Inputs: gleiche Datei, aber je Clip mit eigenem -ss/-t
for _, t := range starts {
// 1) erst die toleranten Input-Flags
args = append(args, ffmpegInputTol...)
// 2) dann die normalen Input-Parameter für diesen Clip
args = append(args,
"-ss", fmt.Sprintf("%.3f", t),
"-t", fmt.Sprintf("%.3f", clipLenSec),
"-i", srcPath,
)
}
// filter_complex: jedes Segment angleichen + concat
var fc strings.Builder
for i := range starts {
// setpts: jedes Segment startet bei 0
fmt.Fprintf(&fc, "[%d:v]scale=720:-2,setsar=1,setpts=PTS-STARTPTS,format=yuv420p[v%d];", i, i)
}
for i := range starts {
fmt.Fprintf(&fc, "[v%d]", i)
}
fmt.Fprintf(&fc, "concat=n=%d:v=1:a=0[v]", len(starts))
args = append(args,
"-filter_complex", fc.String(),
"-map", "[v]",
"-an",
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "28",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
tmp,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg teaser clips failed: %v (%s)", err, strings.TrimSpace(string(out)))
}
_ = os.Remove(outPath)
return os.Rename(tmp, outPath)
}
func prunePreviewCacheDir(previewDir string, maxFrames int, maxAge time.Duration) {
entries, err := os.ReadDir(previewDir)
if err != nil {
return
}
type frame struct {
path string
mt time.Time
}
now := time.Now()
var frames []frame
for _, e := range entries {
name := e.Name()
path := filepath.Join(previewDir, name)
// .part Dateien immer weg
if strings.HasSuffix(name, ".part") {
_ = os.Remove(path)
continue
}
// optional: preview.jpg neu erzeugen lassen, wenn uralt
if name == "preview.jpg" {
if info, err := e.Info(); err == nil {
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
}
}
continue
}
// Nur t_*.jpg verwalten
if strings.HasPrefix(name, "t_") && strings.HasSuffix(name, ".jpg") {
info, err := e.Info()
if err != nil {
continue
}
// alte Frames löschen
if maxAge > 0 && now.Sub(info.ModTime()) > maxAge {
_ = os.Remove(path)
continue
}
frames = append(frames, frame{path: path, mt: info.ModTime()})
}
}
// Anzahl begrenzen: älteste zuerst löschen
if maxFrames > 0 && len(frames) > maxFrames {
sort.Slice(frames, func(i, j int) bool { return frames[i].mt.Before(frames[j].mt) })
toDelete := len(frames) - maxFrames
for i := 0; i < toDelete; i++ {
_ = os.Remove(frames[i].path)
}
}
}
func servePreviewJPEGBytes(w http.ResponseWriter, img []byte) {
w.Header().Set("Content-Type", "image/jpeg")
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)
return
}
jobsMu.Lock()
list := make([]*RecordJob, 0, len(jobs))
for _, j := range jobs {
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)
}
var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts)$`)
func servePreviewHLSFile(w http.ResponseWriter, r *http.Request, id, file string) {
file = strings.TrimSpace(file)
if file == "" || filepath.Base(file) != file || !previewFileRe.MatchString(file) {
http.Error(w, "ungültige file", http.StatusBadRequest)
return
}
isIndex := file == "index.m3u8" || file == "index_hq.m3u8"
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if !ok {
// Job wirklich unbekannt => 404 ist ok
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
// Preview noch nicht initialisiert? Für index => 204 (kein roter Fehler im Browser)
if strings.TrimSpace(job.PreviewDir) == "" {
if isIndex {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
p := filepath.Join(job.PreviewDir, file)
if _, err := os.Stat(p); err != nil {
if isIndex {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
switch strings.ToLower(filepath.Ext(p)) {
case ".m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl; charset=utf-8")
case ".ts":
w.Header().Set("Content-Type", "video/mp2t")
default:
w.Header().Set("Content-Type", "application/octet-stream")
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, p)
}
func rewriteM3U8ToPreviewEndpoint(m3u8 string, id string) string {
lines := strings.Split(m3u8, "\n")
escapedID := url.QueryEscape(id)
for i, line := range lines {
l := strings.TrimSpace(line)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
// Segment/URI-Zeilen umschreiben
lines[i] = "/api/record/preview?id=" + escapedID + "&file=" + url.QueryEscape(l)
}
return strings.Join(lines, "\n")
}
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
}
commonIn := []string{"-y"}
if strings.TrimSpace(userAgent) != "" {
commonIn = append(commonIn, "-user_agent", userAgent)
}
if strings.TrimSpace(httpCookie) != "" {
commonIn = append(commonIn, "-headers", fmt.Sprintf("Cookie: %s\r\n", httpCookie))
}
commonIn = append(commonIn, "-i", m3u8URL)
baseURL := fmt.Sprintf("/api/record/preview?id=%s&file=", url.QueryEscape(jobID))
// ✅ Nur EIN Preview-Transcode pro Job (sonst wird es bei vielen Downloads sehr teuer).
// Wir nutzen das HQ-Playlist-Format (index_hq.m3u8), aber skalieren etwas kleiner.
hqArgs := append(commonIn,
"-vf", "scale=480:-2",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "128k", "-ac", "2",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "4",
"-hls_flags", "delete_segments+append_list+independent_segments",
"-hls_segment_filename", filepath.Join(previewDir, "seg_hq_%05d.ts"),
"-hls_base_url", baseURL,
filepath.Join(previewDir, "index_hq.m3u8"),
)
// Preview-Prozess starten (einfach & robust)
go func(kind string, args []string) {
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()))
}
}("hq", hqArgs)
return nil
}
func extractFirstFrameJPEG(path string) ([]byte, error) {
cmd := exec.Command(
ffmpegPath,
"-hide_banner",
"-loglevel", "error",
"-i", path,
"-frames:v", "1",
"-vf", "scale=720:-2",
"-q:v", "10",
"-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 first-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}
func resolvePathRelativeToApp(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", nil
}
p = filepath.Clean(filepath.FromSlash(p))
if filepath.IsAbs(p) {
return p, nil
}
exe, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exe)
low := strings.ToLower(exeDir)
// Heuristik: go run / tests -> exe liegt in Temp/go-build
isTemp := strings.Contains(low, `\appdata\local\temp`) ||
strings.Contains(low, `\temp\`) ||
strings.Contains(low, `\tmp\`) ||
strings.Contains(low, `\go-build`) ||
strings.Contains(low, `/tmp/`) ||
strings.Contains(low, `/go-build`)
if !isTemp {
return filepath.Join(exeDir, p), nil
}
}
// Fallback: Working Directory (Dev)
wd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Join(wd, p), nil
}
// Frontend (Vite build) als SPA ausliefern: Dateien aus dist, sonst index.html
func registerFrontend(mux *http.ServeMux) {
// Kandidaten: zuerst ENV, dann typische Ordner
candidates := []string{
strings.TrimSpace(os.Getenv("FRONTEND_DIST")),
"web/dist",
"dist",
}
var distAbs string
for _, c := range candidates {
if c == "" {
continue
}
abs, err := resolvePathRelativeToApp(c)
if err != nil {
continue
}
if fi, err := os.Stat(filepath.Join(abs, "index.html")); err == nil && !fi.IsDir() {
distAbs = abs
break
}
}
if distAbs == "" {
fmt.Println("⚠️ Frontend dist nicht gefunden (tried: FRONTEND_DIST, frontend/dist, dist) API läuft trotzdem.")
return
}
fmt.Println("🖼️ Frontend dist:", distAbs)
fileServer := http.FileServer(http.Dir(distAbs))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// /api bleibt bei deinen API-Routen (längeres Pattern gewinnt),
// aber falls mal was durchrutscht:
if strings.HasPrefix(r.URL.Path, "/api/") {
http.NotFound(w, r)
return
}
// 1) Wenn echte Datei existiert -> ausliefern
reqPath := r.URL.Path
if reqPath == "" || reqPath == "/" {
// index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
return
}
// URL-Pfad in Dateisystem-Pfad umwandeln (ohne Traversal)
clean := path.Clean("/" + reqPath) // path.Clean (für URL-Slashes)
rel := strings.TrimPrefix(clean, "/")
onDisk := filepath.Join(distAbs, filepath.FromSlash(rel))
if fi, err := os.Stat(onDisk); err == nil && !fi.IsDir() {
// Statische Assets ruhig cachen (Vite hashed assets)
ext := strings.ToLower(filepath.Ext(onDisk))
if ext != "" && ext != ".html" {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-store")
}
fileServer.ServeHTTP(w, r)
return
}
// 2) SPA-Fallback: alle "Routen" ohne Datei -> index.html
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(distAbs, "index.html"))
})
}
// routes.go (package main)
func registerRoutes(mux *http.ServeMux) *ModelStore {
mux.HandleFunc("/api/cookies", cookiesHandler)
mux.HandleFunc("/api/settings", recordSettingsHandler)
mux.HandleFunc("/api/settings/browse", settingsBrowse)
mux.HandleFunc("/api/record", startRecordingFromRequest)
mux.HandleFunc("/api/record/status", recordStatus)
mux.HandleFunc("/api/record/stop", recordStop)
mux.HandleFunc("/api/record/preview", recordPreview)
mux.HandleFunc("/api/record/list", recordList)
mux.HandleFunc("/api/record/stream", recordStream)
mux.HandleFunc("/api/record/video", recordVideo)
mux.HandleFunc("/api/record/done", recordDoneList)
mux.HandleFunc("/api/record/done/meta", recordDoneMeta)
mux.HandleFunc("/api/record/delete", recordDeleteVideo)
mux.HandleFunc("/api/record/toggle-hot", recordToggleHot)
mux.HandleFunc("/api/record/keep", recordKeepVideo)
mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
mux.HandleFunc("/api/generated/teaser", generatedTeaser)
// Tasks
mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
modelsPath, _ := resolvePathRelativeToApp("data/models_store.db")
fmt.Println("📦 Models DB:", modelsPath)
store := NewModelStore(modelsPath)
if err := store.Load(); err != nil {
fmt.Println("⚠️ models load:", err)
}
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
RegisterModelAPI(mux, store)
// ✅ Frontend (SPA) ausliefern
registerFrontend(mux)
return store
}
// --- main ---
func main() {
loadSettings()
mux := http.NewServeMux()
store := registerRoutes(mux)
go startChaturbateOnlinePoller() // ✅ hält Online-Liste aktuell
go startChaturbateAutoStartWorker(store) // ✅ startet watched+public automatisch
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil {
fmt.Println("❌ HTTP-Server Fehler:", err)
os.Exit(1)
}
}
type RecordRequest struct {
URL string `json:"url"`
Cookie string `json:"cookie,omitempty"`
UserAgent string `json:"userAgent,omitempty"`
}
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL)
if url == "" {
return nil, errors.New("url fehlt")
}
// Duplicate-running guard (identische URL)
jobsMu.Lock()
for _, j := range jobs {
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
jobsMu.Unlock()
return j, nil
}
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: url,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobs[jobID] = job
jobsMu.Unlock()
notifyJobsChanged()
go runJob(ctx, job, req)
return job, nil
}
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 parseCookieString(cookieStr string) map[string]string {
out := map[string]string{}
for _, pair := range strings.Split(cookieStr, ";") {
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if name == "" {
continue
}
out[strings.ToLower(name)] = value
}
return out
}
func hasChaturbateCookies(cookieStr string) bool {
m := parseCookieString(cookieStr)
_, hasCF := m["cf_clearance"]
// akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower)
_, hasSessID := m["session_id"]
_, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt
return hasCF && (hasSessID || hasSessIdAlt)
}
func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
hc := NewHTTPClient(req.UserAgent)
provider := detectProvider(req.URL)
var err error
now := time.Now()
// ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ----
switch provider {
case "chaturbate":
if !hasChaturbateCookies(req.Cookie) {
err = errors.New("cf_clearance und session_id (oder sessionid) Cookies sind für Chaturbate erforderlich")
break
}
s := getSettings()
recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir)
if rerr != nil || strings.TrimSpace(recordDirAbs) == "" {
err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr)
break
}
_ = os.MkdirAll(recordDirAbs, 0o755)
username := extractUsername(req.URL)
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(recordDirAbs, filename)
// Output setzen (kurz locken)
jobsMu.Lock()
job.Output = outPath
jobsMu.Unlock()
notifyJobsChanged()
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
case "mfc":
s := getSettings()
recordDirAbs, rerr := resolvePathRelativeToApp(s.RecordDir)
if rerr != nil || strings.TrimSpace(recordDirAbs) == "" {
err = fmt.Errorf("recordDir auflösung fehlgeschlagen: %v", rerr)
break
}
_ = os.MkdirAll(recordDirAbs, 0o755)
username := extractMFCUsername(req.URL)
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(recordDirAbs, filename)
jobsMu.Lock()
job.Output = outPath
jobsMu.Unlock()
err = RecordStreamMFC(ctx, hc, username, outPath, job)
default:
err = errors.New("unsupported provider")
}
// ---- Finalisieren (EndedAt/Error setzen, dann remux/move OHNE global-lock) ----
end := time.Now()
// Zielstatus bestimmen (Status erst am Ende setzen, damit Progress sichtbar bleibt)
target := JobFinished
var errText string
if err != nil {
if errors.Is(err, context.Canceled) {
target = JobStopped
} else {
target = JobFailed
errText = err.Error()
}
}
// EndedAt + Error speichern (kurz locken)
jobsMu.Lock()
job.EndedAt = &end
if errText != "" {
job.Error = errText
}
// Output lokal kopieren, damit wir ohne lock weiterarbeiten können
out := strings.TrimSpace(job.Output)
jobsMu.Unlock()
// Falls Output fehlt (z.B. provider error), direkt final status setzen
if out == "" {
setJobPhase(job, "finalizing", 95)
jobsMu.Lock()
job.Status = target
job.Phase = ""
job.Progress = 100
jobsMu.Unlock()
return
}
// 1) Remux (auch bei STOP/FAILED best-effort)
setJobPhase(job, "remuxing", 45)
if newOut, err2 := maybeRemuxTS(out); err2 == nil && strings.TrimSpace(newOut) != "" {
out = strings.TrimSpace(newOut)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
}
// 2) Move to done (best-effort)
setJobPhase(job, "moving", 80)
if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" {
out = strings.TrimSpace(moved)
jobsMu.Lock()
job.Output = out
jobsMu.Unlock()
}
// 3) Finalize
setJobPhase(job, "finalizing", 95)
// Jetzt erst finalen Status setzen
jobsMu.Lock()
job.Status = target
job.Phase = ""
job.Progress = 100
finalOut := strings.TrimSpace(job.Output)
finalStatus := job.Status
jobsMu.Unlock()
notifyJobsChanged()
// ---- Nach Abschluss Assets erzeugen (Preview + Teaser) ----
// nur bei Finished/Stopped, und nur wenn die Datei existiert
if finalOut != "" && (finalStatus == JobFinished || finalStatus == JobStopped) {
go func(videoPath string) {
fi, statErr := os.Stat(videoPath)
if statErr != nil || fi.IsDir() || fi.Size() <= 0 {
return
}
// ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix)
base := filepath.Base(videoPath)
id := strings.TrimSuffix(base, filepath.Ext(base))
id = stripHotPrefix(id)
if id == "" {
return
}
// ✅ /generated/<id>/thumbs.jpg + /generated/<id>/preview.mp4
assetDir, gerr := ensureGeneratedDir(id)
if gerr != nil || strings.TrimSpace(assetDir) == "" {
fmt.Println("⚠️ generated dir:", gerr)
return
}
thumbPath := filepath.Join(assetDir, "thumbs.jpg")
if tfi, err := os.Stat(thumbPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 {
genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
t := 0.0
if dur, derr := durationSecondsCached(genCtx, videoPath); derr == nil && dur > 0 {
t = dur * 0.5
}
img, e1 := extractFrameAtTimeJPEG(videoPath, t)
if e1 != nil || len(img) == 0 {
img, e1 = extractLastFrameJPEG(videoPath)
if e1 != nil || len(img) == 0 {
img, e1 = extractFirstFrameJPEG(videoPath)
}
}
if e1 == nil && len(img) > 0 {
if err := atomicWriteFile(thumbPath, img); err != nil {
fmt.Println("⚠️ thumb write:", err)
}
}
}
previewPath := filepath.Join(assetDir, "preview.mp4")
if tfi, err := os.Stat(previewPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 {
genSem <- struct{}{}
defer func() { <-genSem }()
genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := generateTeaserClipsMP4(genCtx, videoPath, previewPath, 1.0, 18); err != nil {
fmt.Println("⚠️ preview clips:", err)
}
}
}(finalOut)
}
}
func recordVideo(w http.ResponseWriter, r *http.Request) {
// ✅ 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 doneDir, dann recordDir
candidates := []string{
filepath.Join(doneAbs, file),
filepath.Join(recordAbs, file),
}
// Falls UI noch ".ts" kennt, die Datei aber schon als ".mp4" existiert:
if ext == ".ts" {
mp4File := strings.TrimSuffix(file, ext) + ".mp4"
candidates = append(candidates,
filepath.Join(doneAbs, mp4File),
filepath.Join(recordAbs, mp4File),
)
}
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, "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
}
}
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 durationSecondsCacheOnly(path string, fi os.FileInfo) float64 {
durCache.mu.Lock()
e, ok := durCache.m[path]
durCache.mu.Unlock()
if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 {
return e.sec
}
return 0
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
// 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 (für später)
// 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"
}
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([]*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
}
// 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
}
stemForSort := func(j *RecordJob) string {
// ohne ext und ohne HOT Prefix
f := fileForSort(j)
return strings.TrimSuffix(f, filepath.Ext(f))
}
modelForSort := func(j *RecordJob) string {
stem := stemForSort(j)
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))
}
durationForSort := func(j *RecordJob) (sec float64, ok bool) {
if j.DurationSeconds > 0 {
return j.DurationSeconds, true
}
return 0, false
}
list := make([]*RecordJob, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".mp4" && ext != ".ts" {
continue
}
full := filepath.Join(doneAbs, name)
fi, err := os.Stat(full)
if err != nil || fi.IsDir() {
continue
}
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 := durationSecondsCacheOnly(full, fi)
list = append(list, &RecordJob{
ID: base,
Output: full,
Status: JobFinished,
StartedAt: start,
EndedAt: &t,
DurationSeconds: dur,
SizeBytes: fi.Size(),
})
}
// 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 "model_asc":
ma, mb := modelForSort(a), modelForSort(b)
if ma != mb {
return ma < mb
}
if !ta.Equal(tb) {
return ta.After(tb)
}
return fileForSort(a) < fileForSort(b)
case "model_desc":
ma, mb := modelForSort(a), modelForSort(b)
if ma != mb {
return ma > mb
}
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)
}
})
// Pagination (nach Sort!)
if pageSize > 0 {
if page <= 0 {
page = 1
}
startIdx := (page - 1) * pageSize
if startIdx >= len(list) {
list = []*RecordJob{}
} else {
endIdx := startIdx + pageSize
if endIdx > len(list) {
endIdx = len(list)
}
list = list[startIdx:endIdx]
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(list)
}
type doneMetaResp struct {
Count int `json:"count"`
}
func recordDoneMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
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) == "" {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
entries, err := os.ReadDir(doneAbs)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, doneMetaResp{Count: 0})
return
}
http.Error(w, "readdir fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
cnt := 0
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
// gleiche Allowlist wie bei deinen Done-Aktionen (HOT/keep etc.)
if ext != ".mp4" && ext != ".ts" {
continue
}
cnt++
}
writeJSON(w, http.StatusOK, doneMetaResp{Count: cnt})
}
type durationReq struct {
Files []string `json:"files"`
}
type durationItem struct {
File string `json:"file"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
Error string `json:"error,omitempty"`
}
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)
if file == "" {
http.Error(w, "file leer", http.StatusBadRequest)
return
}
// Pfad absichern: nur Dateiname, keine Unterordner/Traversal
clean := filepath.Clean(file)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = clean
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
}
target := filepath.Join(doneAbs, file)
fi, err := os.Stat(target)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
// löschen mit retry (Windows file-lock)
if err := removeWithRetry(target); err != nil {
if runtime.GOOS == "windows" {
if isSharingViolation(err) {
http.Error(w, "datei wird gerade verwendet (Player offen). Bitte kurz stoppen und erneut versuchen.", http.StatusConflict)
return
}
}
http.Error(w, "löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
// ✅ generated Assets löschen (best effort)
base := strings.TrimSuffix(file, filepath.Ext(file))
canonical := stripHotPrefix(base)
// Neu: /generated/<id>/
if genAbs, _ := generatedRoot(); strings.TrimSpace(genAbs) != "" {
if strings.TrimSpace(canonical) != "" {
_ = os.RemoveAll(filepath.Join(genAbs, canonical))
}
// falls irgendwo alte Assets mit HOT im Ordnernamen liegen
if strings.TrimSpace(base) != "" && base != canonical {
_ = os.RemoveAll(filepath.Join(genAbs, base))
}
}
// Legacy-Cleanup (optional)
thumbsLegacy, _ := generatedThumbsRoot()
teaserLegacy, _ := generatedTeaserRoot()
if strings.TrimSpace(thumbsLegacy) != "" {
_ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical))
_ = os.RemoveAll(filepath.Join(thumbsLegacy, base))
_ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg"))
_ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg"))
}
if strings.TrimSpace(teaserLegacy) != "" {
_ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, base+".mp4"))
}
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,
})
}
func serveVideoFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := openForReadShareDelete(path)
if err != nil {
http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
fi, err := f.Stat()
if err != nil || fi.IsDir() || fi.Size() == 0 {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
// ServeContent unterstützt Range Requests (wichtig für Video)
http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f)
}
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)
if file == "" {
http.Error(w, "file leer", http.StatusBadRequest)
return
}
// Pfad absichern
clean := filepath.Clean(file)
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || filepath.IsAbs(clean) {
http.Error(w, "ungültiger file", http.StatusBadRequest)
return
}
file = clean
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
}
src := filepath.Join(doneAbs, file)
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
keepDir := filepath.Join(doneAbs, "keep")
if err := os.MkdirAll(keepDir, 0o755); err != nil {
http.Error(w, "keep dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
dst := filepath.Join(keepDir, file)
// falls schon vorhanden => Fehler
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
}
// 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
}
// ✅ generated Assets löschen (best effort)
base := strings.TrimSuffix(file, filepath.Ext(file))
canonical := stripHotPrefix(base)
// Neu: /generated/<id>/
if genAbs, _ := generatedRoot(); strings.TrimSpace(genAbs) != "" {
if strings.TrimSpace(canonical) != "" {
_ = os.RemoveAll(filepath.Join(genAbs, canonical))
}
// falls irgendwo alte Assets mit HOT im Ordnernamen liegen
if strings.TrimSpace(base) != "" && base != canonical {
_ = os.RemoveAll(filepath.Join(genAbs, base))
}
}
// Legacy-Cleanup (optional)
thumbsLegacy, _ := generatedThumbsRoot()
teaserLegacy, _ := generatedTeaserRoot()
if strings.TrimSpace(thumbsLegacy) != "" {
_ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical))
_ = os.RemoveAll(filepath.Join(thumbsLegacy, base))
_ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg"))
_ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg"))
}
if strings.TrimSpace(teaserLegacy) != "" {
_ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4"))
_ = os.Remove(filepath.Join(teaserLegacy, base+".mp4"))
}
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,
})
}
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)
// 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()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir ist leer", http.StatusBadRequest)
return
}
src := filepath.Join(doneAbs, file)
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "datei nicht gefunden", http.StatusNotFound)
return
}
http.Error(w, "stat fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if fi.IsDir() {
http.Error(w, "ist ein verzeichnis", http.StatusBadRequest)
return
}
// toggle: HOT Prefix
newFile := file
if strings.HasPrefix(file, "HOT ") {
newFile = strings.TrimPrefix(file, "HOT ")
} else {
newFile = "HOT " + file
}
dst := filepath.Join(doneAbs, newFile)
if _, err := os.Stat(dst); err == nil {
http.Error(w, "ziel existiert bereits", http.StatusConflict)
return
} else if !os.IsNotExist(err) {
http.Error(w, "stat ziel fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if err := 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 mehr!
// Assets bleiben canonical: generated/thumbs/<id>.jpg und generated/teaser/<id>_teaser.mp4 (ohne HOT)
canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file)))
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, // optional fürs Frontend
})
}
func maybeRemuxTS(path string) (string, error) {
path = strings.TrimSpace(path)
if path == "" {
return "", nil
}
if !strings.EqualFold(filepath.Ext(path), ".ts") {
return "", nil
}
mp4 := strings.TrimSuffix(path, filepath.Ext(path)) + ".mp4"
// remux (ohne neu encoden)
if err := remuxTSToMP4(path, mp4); err != nil {
return "", err
}
_ = os.Remove(path) // TS entfernen, wenn MP4 ok
return mp4, nil
}
func moveFile(src, dst string) error {
// zuerst Rename (schnell)
if err := os.Rename(src, dst); err == nil {
return nil
} else {
// Fallback: Copy+Remove (z.B. bei EXDEV)
in, err2 := os.Open(src)
if err2 != nil {
return err
}
defer in.Close()
out, err2 := os.Create(dst)
if err2 != nil {
return err
}
if _, err2 := io.Copy(out, in); err2 != nil {
out.Close()
return err2
}
if err2 := out.Close(); err2 != nil {
return err2
}
return os.Remove(src)
}
}
const windowsSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
func isSharingViolation(err error) bool {
if runtime.GOOS != "windows" {
return false
}
// Windows: ERROR_SHARING_VIOLATION = 32, ERROR_LOCK_VIOLATION = 33
var pe *os.PathError
if errors.As(err, &pe) {
if errno, ok := pe.Err.(syscall.Errno); ok {
return errno == syscall.Errno(32) || errno == syscall.Errno(33)
}
}
// Fallback über Text
s := strings.ToLower(err.Error())
return strings.Contains(s, "sharing violation") ||
strings.Contains(s, "used by another process") ||
strings.Contains(s, "wird von einem anderen prozess verwendet")
}
func removeWithRetry(path string) error {
var err error
for i := 0; i < 40; i++ { // ~4s bei 100ms
err = os.Remove(path)
if err == nil {
return nil
}
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
return err
}
return err
}
func renameWithRetry(oldPath, newPath string) error {
var err error
for i := 0; i < 40; i++ {
err = os.Rename(oldPath, newPath)
if err == nil {
return nil
}
if isSharingViolation(err) {
time.Sleep(100 * time.Millisecond)
continue
}
return err
}
return err
}
func moveToDoneDir(outputPath string) (string, error) {
outputPath = strings.TrimSpace(outputPath)
if outputPath == "" {
return "", nil
}
s := getSettings()
// ✅ doneDir relativ zur exe auflösen (funktion hast du schon)
doneDirAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
return "", err
}
if err := os.MkdirAll(doneDirAbs, 0o755); err != nil {
return "", err
}
dst := filepath.Join(doneDirAbs, filepath.Base(outputPath))
if err := moveFile(outputPath, dst); err != nil {
return "", err
}
// ✅ Streaming-Optimierung
if strings.EqualFold(filepath.Ext(dst), ".mp4") {
if err := ensureFastStartMP4(dst); err != nil {
fmt.Println("⚠️ faststart:", err)
}
}
return dst, nil
}
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]
if ok {
job.Phase = "stopping"
job.Progress = 10
}
jobsMu.Unlock()
if ok {
notifyJobsChanged() // ✅ 1) UI sofort updaten (Phase/Progress)
}
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
// Preview wird bei dir über ctx beendet kill kann bleiben, ist aber oft nil.
if job.previewCmd != nil && job.previewCmd.Process != nil {
_ = job.previewCmd.Process.Kill()
job.previewCmd = nil
}
if job.cancel != nil {
job.cancel()
}
notifyJobsChanged() // ✅ 2) optional: nach Cancel/Kill nochmal pushen
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(job)
}
// --- DVR-ähnlicher Recorder-Ablauf ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream(
ctx context.Context,
hc *HTTPClient,
domain string,
username string,
outputPath string,
httpCookie string,
job *RecordJob,
) error {
// 1) Seite laden
// Domain sauber zusammenbauen (mit/ohne Slash)
base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
// 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
hlsURL, err := ParseStream(body)
if err != nil {
return fmt.Errorf("stream-parsing: %w", err)
}
// 3) Playlist holen (wie stream.GetPlaylist im DVR)
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist abrufen: %w", err)
}
if job != nil && strings.TrimSpace(job.PreviewDir) == "" {
previewDir := filepath.Join(os.TempDir(), "rec_preview", job.ID)
jobsMu.Lock()
job.PreviewDir = previewDir
jobsMu.Unlock()
if err := startPreviewHLS(ctx, job.ID, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
}
}
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben
if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err)
}
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
_ = duration // aktuell unbenutzt
return nil
})
if err != nil {
return fmt.Errorf("watch segments: %w", err)
}
return nil
}
// RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf.
// In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren.
// RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC).
func RecordStreamMFC(
ctx context.Context,
hc *HTTPClient,
username string,
outputPath string,
job *RecordJob,
) error {
mfc := NewMyFreeCams(username)
// optional, aber sinnvoll: nur aufnehmen wenn Public
st, err := mfc.GetStatus()
if err != nil {
return fmt.Errorf("mfc status: %w", err)
}
if st != StatusPublic {
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
}
m3u8URL, err := mfc.GetVideoURL(false)
if err != nil {
return fmt.Errorf("mfc get video url: %w", err)
}
if strings.TrimSpace(m3u8URL) == "" {
return fmt.Errorf("mfc: keine m3u8 URL gefunden")
}
// ✅ Preview starten
if job != nil && job.PreviewDir == "" {
previewDir := filepath.Join(os.TempDir(), "preview_"+job.ID)
job.PreviewDir = previewDir
if err := startPreviewHLS(ctx, job.ID, m3u8URL, previewDir, "", hc.userAgent); err != nil {
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
job.PreviewDir = "" // rollback
}
}
// Aufnahme starten
return handleM3U8Mode(ctx, m3u8URL, outputPath)
}
func detectProvider(raw string) string {
s := strings.ToLower(raw)
if strings.Contains(s, "chaturbate.com") {
return "chaturbate"
}
if strings.Contains(s, "myfreecams.com") {
return "mfc"
}
return "unknown"
}
// --- helper ---
func extractUsername(input string) string {
input = strings.TrimSpace(input)
input = strings.TrimPrefix(input, "https://")
input = strings.TrimPrefix(input, "http://")
input = strings.TrimPrefix(input, "www.")
if strings.HasPrefix(input, "chaturbate.com/") {
input = strings.TrimPrefix(input, "chaturbate.com/")
}
// alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.)
if idx := strings.IndexAny(input, "/?#"); idx != -1 {
input = input[:idx]
}
// zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen
return strings.Trim(input, "/\\")
}
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
func addCookiesFromString(req *http.Request, cookieStr string) {
if cookieStr == "" {
return
}
pairs := strings.Split(cookieStr, ";")
for _, pair := range pairs {
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
if len(parts) != 2 {
continue
}
name := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if name == "" {
continue
}
req.AddCookie(&http.Cookie{
Name: name,
Value: value,
})
}
}
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
func ParseStream(html string) (string, error) {
matches := roomDossierRegexp.FindStringSubmatch(html)
if len(matches) == 0 {
return "", errors.New("room dossier nicht gefunden")
}
// DVR-Style Unicode-Decode
decoded, err := strconv.Unquote(
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
)
if err != nil {
return "", fmt.Errorf("Unicode-decode failed: %w", err)
}
var rd struct {
HLSSource string `json:"hls_source"`
}
if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
return "", fmt.Errorf("JSON-parse failed: %w", err)
}
if rd.HLSSource == "" {
return "", errors.New("kein HLS-Quell-URL im JSON")
}
return rd.HLSSource, nil
}
// --- Playlist/WatchSegments wie gehabt ---
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
type Resolution struct {
Framerate map[int]string
Width int
}
// nimmt jetzt *HTTPClient entgegen
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
if hlsSource == "" {
return nil, errors.New("HLS-URL leer")
}
req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie)
if err != nil {
return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err)
}
defer resp.Body.Close()
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil || listType != m3u8.MASTER {
return nil, errors.New("keine gültige Master-Playlist")
}
master := playlist.(*m3u8.MasterPlaylist)
var bestURI string
var bestWidth int
var bestFramerate int
for _, variant := range master.Variants {
if variant == nil || variant.Resolution == "" {
continue
}
parts := strings.Split(variant.Resolution, "x")
if len(parts) != 2 {
continue
}
width, err := strconv.Atoi(parts[1])
if err != nil {
continue
}
fr := 30
if strings.Contains(variant.Name, "FPS:60.0") {
fr = 60
}
if width > bestWidth || (width == bestWidth && fr > bestFramerate) {
bestWidth = width
bestFramerate = fr
bestURI = variant.URI
}
}
if bestURI == "" {
return nil, errors.New("keine gültige Auflösung gefunden")
}
root := hlsSource[:strings.LastIndex(hlsSource, "/")+1]
return &Playlist{
PlaylistURL: root + bestURI,
RootURL: root,
Resolution: bestWidth,
Framerate: bestFramerate,
}, nil
}
// nutzt ebenfalls *HTTPClient
func (p *Playlist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 60 // statt 5
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Playlist holen
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline")
}
time.Sleep(2 * time.Second)
continue
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close()
if err != nil || listType != m3u8.MEDIA {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
time.Sleep(2 * time.Second)
continue
}
media := playlist.(*m3u8.MediaPlaylist)
newSegment := false
for _, segment := range media.Segments {
if segment == nil {
continue
}
if int64(segment.SeqId) <= lastSeq {
continue
}
lastSeq = int64(segment.SeqId)
newSegment = true
segmentURL := p.RootURL + segment.URI
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
if err != nil {
continue
}
segResp, err := hc.client.Do(segReq)
if err != nil {
continue
}
data, err := io.ReadAll(segResp.Body)
segResp.Body.Close()
if err != nil || len(data) == 0 {
continue
}
if err := handler(data, segment.Duration); err != nil {
return err
}
}
if newSegment {
emptyRounds = 0
} else {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return errors.New("🛑 Keine neuen HLS-Segmente empfangen Stream vermutlich beendet oder offline.")
}
}
time.Sleep(1 * time.Second)
}
}
/* ───────────────────────────────
MyFreeCams (übernommener Flow)
─────────────────────────────── */
type MyFreeCams struct {
Username string
Attrs map[string]string
VideoURL string
}
func NewMyFreeCams(username string) *MyFreeCams {
return &MyFreeCams{
Username: username,
Attrs: map[string]string{},
}
}
func (m *MyFreeCams) GetWebsiteURL() string {
return "https://www.myfreecams.com/#" + m.Username
}
func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
if !refresh && m.VideoURL != "" {
return m.VideoURL, nil
}
// Prüfen, ob alle benötigten Attribute vorhanden sind
if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok {
return "", nil
}
sid := m.Attrs["data-cam-preview-server-id-value"]
midBase := m.Attrs["data-cam-preview-model-id-value"]
isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true"
midInt, err := strconv.Atoi(midBase)
if err != nil {
return "", fmt.Errorf("model-id parse error: %w", err)
}
mid := 100000000 + midInt
a := ""
if isWzobs {
a = "a_"
}
playlistURL := fmt.Sprintf(
"https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8",
sid, a, mid,
)
// Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken
u, err := getWantedResolutionPlaylist(playlistURL)
if err != nil {
return "", err
}
m.VideoURL = u
return m.VideoURL, nil
}
func (m *MyFreeCams) GetStatus() (Status, error) {
// 1) share-Seite prüfen (existiert/nicht existiert)
shareURL := "https://share.myfreecams.com/" + m.Username
resp, err := http.Get(shareURL)
if err != nil {
return StatusUnknown, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return StatusNotExist, nil
}
if resp.StatusCode != 200 {
return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode)
}
// wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML)
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return StatusUnknown, err
}
// 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist
start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?"))
if start == -1 {
// ohne tracking Parameter -> behandeln wie nicht existent
return StatusNotExist, nil
}
end := bytes.IndexByte(bodyBytes[start:], '"')
if end == -1 {
return StatusUnknown, errors.New("tracking url parse failed")
}
raw := string(bodyBytes[start : start+end])
u, err := url.Parse(raw)
if err != nil {
return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err)
}
qs := u.Query()
if qs.Get("model_id") == "" {
return StatusNotExist, nil
}
// 3) HTML parsen und <div class="campreview" ...> Attribute auslesen
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
if err != nil {
return StatusUnknown, err
}
params := doc.Find(".campreview").First()
if params.Length() == 0 {
// keine campreview -> offline
return StatusOffline, nil
}
attrs := map[string]string{}
params.Each(func(_ int, s *goquery.Selection) {
for _, a := range []string{
"data-cam-preview-server-id-value",
"data-cam-preview-model-id-value",
"data-cam-preview-is-wzobs-value",
} {
if v, ok := s.Attr(a); ok {
attrs[a] = v
}
}
})
m.Attrs = attrs
// 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln
uStr, err := m.GetVideoURL(true)
if err != nil {
return StatusUnknown, err
}
if uStr != "" {
return StatusPublic, nil
}
// campreview vorhanden, aber keine playable url -> „PRIVATE“
return StatusPrivate, nil
}
func runMFC(ctx context.Context, username string, outArg string) error {
mfc := NewMyFreeCams(username)
st, err := mfc.GetStatus()
if err != nil {
return err
}
if st != StatusPublic {
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
}
m3u8URL, err := mfc.GetVideoURL(false)
if err != nil {
return err
}
if m3u8URL == "" {
return errors.New("keine m3u8 URL gefunden")
}
return handleM3U8Mode(ctx, m3u8URL, outArg)
}
/* ───────────────────────────────
Gemeinsame HLS/M3U8-Helper (MFC)
─────────────────────────────── */
func getWantedResolutionPlaylist(playlistURL string) (string, error) {
// Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück.
resp, err := http.Get(playlistURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return "", fmt.Errorf("m3u8 parse: %w", err)
}
if listType == m3u8.MEDIA {
return playlistURL, nil
}
master := playlist.(*m3u8.MasterPlaylist)
var bestURI string
var bestWidth int
var bestFramerate float64
for _, v := range master.Variants {
if v == nil {
continue
}
// Resolution kommt als "WxH" wir nutzen die Höhe als Vergleichswert.
w := 0
if v.Resolution != "" {
parts := strings.Split(v.Resolution, "x")
if len(parts) == 2 {
if ww, err := strconv.Atoi(parts[1]); err == nil {
w = ww
}
}
}
fr := 30.0
if v.FrameRate > 0 {
fr = v.FrameRate
} else if strings.Contains(v.Name, "FPS:60") {
fr = 60
}
if w > bestWidth || (w == bestWidth && fr > bestFramerate) {
bestWidth = w
bestFramerate = fr
bestURI = v.URI
}
}
if bestURI == "" {
return "", errors.New("Master-Playlist ohne gültige Varianten")
}
// Absolutieren
root := playlistURL[:strings.LastIndex(playlistURL, "/")+1]
if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") {
return bestURI, nil
}
return root + bestURI, nil
}
func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error {
// Validierung
u, err := url.Parse(m3u8URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige URL: %q", m3u8URL)
}
// HTTP-Check MIT Context
req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
if strings.TrimSpace(outFile) == "" {
return errors.New("output file path leer")
}
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
cmd := exec.CommandContext(
ctx,
ffmpegPath,
"-y",
"-i", m3u8URL,
"-c", "copy",
outFile,
)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return ctx.Err()
}
return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err)
}
return nil
}
/* ───────────────────────────────
Kleine Helper für MFC
─────────────────────────────── */
func extractMFCUsername(input string) string {
if strings.Contains(input, "myfreecams.com/#") {
i := strings.Index(input, "#")
if i >= 0 && i < len(input)-1 {
return strings.TrimSpace(input[i+1:])
}
return ""
}
return strings.TrimSpace(input)
}
func readLine() string {
r := bufio.NewReader(os.Stdin)
s, _ := r.ReadString('\n')
return strings.TrimRight(s, "\r\n")
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}