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{}
)
// 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 (nach Save):", ffmpegPath)
ffprobePath = detectFFprobePath()
fmt.Println("🔍 ffprobePath (nach Save):", 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, "
Just a moment...") {
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 generatedThumbsRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
}
func generatedTeaserRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
}
func ensureGeneratedDirs() error {
thumbs, err := generatedThumbsRoot()
if err != nil {
return err
}
teaser, err := generatedTeaserRoot()
if err != nil {
return err
}
if err := os.MkdirAll(thumbs, 0o755); err != nil {
return err
}
if err := os.MkdirAll(teaser, 0o755); err != nil {
return err
}
return nil
}
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)
candidates := []string{
filepath.Join(doneAbs, id+".mp4"),
filepath.Join(doneAbs, id+".ts"),
filepath.Join(recordAbs, id+".mp4"),
filepath.Join(recordAbs, id+".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
}
thumbsRoot, _ := generatedThumbsRoot()
thumbDir := filepath.Join(thumbsRoot, id)
_ = os.MkdirAll(thumbDir, 0o755)
// ✅ Frame-Caching für t=... (für alte "clips" Logik)
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) // auf ~Sekunden runden
if secI < 0 {
secI = 0
}
framePath := filepath.Join(thumbDir, 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
}
// wenn das scheitert, unten weiter mit preview.jpg
}
}
// ✅ Statisches Preview (einmalig) -> generated/thumbs//preview.jpg
previewJpg := filepath.Join(thumbDir, "preview.jpg")
if fi, err := os.Stat(previewJpg); err == nil && !fi.IsDir() && fi.Size() > 0 {
servePreviewJPEGFile(w, r, previewJpg)
return
}
// Besseres Preview: wenn Duration bekannt, nimm Mitte; sonst fallback
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(previewJpg, 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)
}
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",
"-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",
// ✅ WICHTIG: Output-Format festnageln, weil tmp auf ".part" endet
"-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, err := sanitizeID(r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := ensureGeneratedDirs(); err != nil {
http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
teaserRoot, _ := generatedTeaserRoot()
// ✅ neuer Name
teaserPath := filepath.Join(teaserRoot, id+"_teaser.mp4")
// ✅ optional: Legacy-Name unterstützen (falls bereits welche existieren)
legacyPath := filepath.Join(teaserRoot, id+".mp4")
// Cache hit (neu)
if fi, err := os.Stat(teaserPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, teaserPath)
return
}
// Cache hit (legacy)
if fi, err := os.Stat(legacyPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
serveTeaserFile(w, r, legacyPath)
return
}
// Quelle finden
srcPath, err := findFinishedFileByID(id)
if err != nil {
http.Error(w, "teaser nicht verfügbar", http.StatusNotFound)
return
}
// Generieren (limitiert parallel)
genSem <- struct{}{}
defer func() { <-genSem }()
genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
defer cancel()
if err := os.MkdirAll(filepath.Dir(teaserPath), 0o755); err != nil {
http.Error(w, "teaser dir error: "+err.Error(), http.StatusInternalServerError)
return
}
// ✅ NEU: Teaser aus mehreren 1s-Clips erzeugen
if err := generateTeaserClipsMP4(genCtx, srcPath, teaserPath, 1.0, 18); err != nil {
http.Error(w, "teaser erzeugen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
serveTeaserFile(w, r, teaserPath)
}
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 {
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/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)
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()
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()
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()
// ---- 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
}
// generated-Ordner im EXE-Pfad
genRoot, gerr := resolvePathRelativeToApp(filepath.Join("generated"))
if gerr != nil || strings.TrimSpace(genRoot) == "" {
fmt.Println("⚠️ generated root:", gerr)
return
}
thumbsDir := filepath.Join(genRoot, "thumbs")
teaserDir := filepath.Join(genRoot, "teaser")
_ = os.MkdirAll(thumbsDir, 0o755)
_ = os.MkdirAll(teaserDir, 0o755)
// ID = Dateiname ohne Endung
base := filepath.Base(videoPath)
id := strings.TrimSuffix(base, filepath.Ext(base))
if id == "" {
return
}
// --- Atomic writer (lokal) ---
writeAtomic := func(dst string, data []byte) error {
dir := filepath.Dir(dst)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := dst + ".part"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
_ = os.Remove(tmp)
return err
}
_ = os.Remove(dst)
return os.Rename(tmp, dst)
}
// --- 1) Thumb (ein Frame) ---
thumbPath := filepath.Join(thumbsDir, id+".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 := writeAtomic(thumbPath, img); err != nil {
fmt.Println("⚠️ thumb write:", err)
}
}
}
// --- 2) Teaser (mp4 aus 1s-clips) ---
teaserPath := filepath.Join(teaserDir, id+"_teaser.mp4")
if tfi, err := os.Stat(teaserPath); 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, teaserPath, 1.0, 18); err != nil {
fmt.Println("⚠️ teaser 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))
thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
if strings.TrimSpace(thumbsAbs) != "" {
// Falls du thumbs als Ordner pro Video nutzt (thumbs//...)
_ = os.RemoveAll(filepath.Join(thumbsAbs, base))
// Falls du thumbs als Datei nutzt (thumbs/.jpg)
_ = os.Remove(filepath.Join(thumbsAbs, base+".jpg"))
// Falls du zusätzlich frame-files im Root ablegst (thumbs/_*.jpg)
if entries, err := os.ReadDir(thumbsAbs); err == nil {
prefix := base + "_"
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
_ = os.Remove(filepath.Join(thumbsAbs, e.Name()))
}
}
}
}
if strings.TrimSpace(teaserAbs) != "" {
_ = os.Remove(filepath.Join(teaserAbs, base+"_teaser.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))
thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
if strings.TrimSpace(thumbsAbs) != "" {
_ = os.RemoveAll(filepath.Join(thumbsAbs, base))
_ = os.Remove(filepath.Join(thumbsAbs, base+".jpg"))
if entries, err := os.ReadDir(thumbsAbs); err == nil {
prefix := base + "_"
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
_ = os.Remove(filepath.Join(thumbsAbs, e.Name()))
}
}
}
}
if strings.TrimSpace(teaserAbs) != "" {
_ = os.Remove(filepath.Join(teaserAbs, 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
}
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
}
// ✅ NEU: generated Assets umbenennen (best effort)
oldBase := strings.TrimSuffix(file, filepath.Ext(file))
newBase := strings.TrimSuffix(newFile, filepath.Ext(newFile))
thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
// thumbs/.jpg
if strings.TrimSpace(thumbsAbs) != "" {
oldThumb := filepath.Join(thumbsAbs, oldBase+".jpg")
newThumb := filepath.Join(thumbsAbs, newBase+".jpg")
if _, err := os.Stat(oldThumb); err == nil {
if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) {
_ = renameWithRetry(oldThumb, newThumb)
} else {
// wenn Ziel existiert, alten löschen (keine Duplikate)
_ = os.Remove(oldThumb)
}
}
}
// teaser/_teaser.mp4
if strings.TrimSpace(teaserAbs) != "" {
oldTeaser := filepath.Join(teaserAbs, oldBase+"_teaser.mp4")
newTeaser := filepath.Join(teaserAbs, newBase+"_teaser.mp4")
if _, err := os.Stat(oldTeaser); err == nil {
if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) {
_ = renameWithRetry(oldTeaser, newTeaser)
} else {
_ = os.Remove(oldTeaser)
}
}
// optional: legacy teaser name ohne Suffix (falls noch vorhanden)
oldLegacy := filepath.Join(teaserAbs, oldBase+".mp4")
newLegacy := filepath.Join(teaserAbs, newBase+".mp4")
if _, err := os.Stat(oldLegacy); err == nil {
if _, err2 := os.Stat(newLegacy); os.IsNotExist(err2) {
_ = renameWithRetry(oldLegacy, newLegacy)
} else {
_ = os.Remove(oldLegacy)
}
}
}
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,
})
}
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 {
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()
}
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 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
}