nsfwapp/backend/main.go
2025-12-19 17:52:14 +01:00

1788 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"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"`
model string `json:"model"`
SourceURL string `json:"sourceUrl"`
Output string `json:"output"`
Status JobStatus `json:"status"`
StartedAt time.Time `json:"startedAt"`
EndedAt *time.Time `json:"endedAt,omitempty"`
Error string `json:"error,omitempty"`
PreviewDir string `json:"-"`
previewCmd *exec.Cmd `json:"-"`
cancel context.CancelFunc `json:"-"`
}
var (
jobs = map[string]*RecordJob{}
jobsMu = sync.Mutex{}
)
type RecorderSettings struct {
RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"`
}
var (
settingsMu sync.Mutex
settings = RecorderSettings{
RecordDir: "/records",
DoneDir: "/records/done",
}
settingsFile = "recorder_settings.json"
)
func getSettings() RecorderSettings {
settingsMu.Lock()
defer settingsMu.Unlock()
return settings
}
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))
}
settingsMu.Lock()
settings = s
settingsMu.Unlock()
}
}
// Ordner sicherstellen
s := getSettings()
_ = os.MkdirAll(s.RecordDir, 0o755)
_ = os.MkdirAll(s.DoneDir, 0o755)
}
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))
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
}
settingsMu.Lock()
settings = in
settingsMu.Unlock()
saveSettingsToDisk()
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" {
http.Error(w, "target muss record oder done sein", http.StatusBadRequest)
return
}
p, err := dialog.Directory().Title("Ordner auswählen").Browse()
if err != nil {
// User cancelled → 204 No Content ist praktisch fürs Frontend
if strings.Contains(strings.ToLower(err.Error()), "cancel") {
w.WriteHeader(http.StatusNoContent)
return
}
http.Error(w, "ordnerauswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
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("ffmpeg",
"-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
}
func extractLastFrameJPEG(path string) ([]byte, error) {
// “Letzter Frame” über -sseof nahe Dateiende.
// Bei laufenden Aufnahmen kann das manchmal fehlschlagen -> Fallback oben.
cmd := exec.Command(
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-sseof", "-0.1",
"-i", path,
"-frames:v", "1",
"-q:v", "4",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg last-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}
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 mode: ?file=index.m3u8 / seg_00001.ts / index_hq.m3u8 ...
if file := r.URL.Query().Get("file"); file != "" {
servePreviewHLSFile(w, r, id, file)
return
}
// sonst: JPEG Fallback
jobsMu.Lock()
job, ok := jobs[id]
jobsMu.Unlock()
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
outPath := strings.TrimSpace(job.Output)
if outPath == "" {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
outPath = filepath.Clean(outPath)
// ✅ Basic hardening: relative Pfade dürfen nicht mit ".." anfangen.
// (Absolute Pfade wie "/records/x.mp4" oder "C:\records\x.mp4" sind ok.)
if !filepath.IsAbs(outPath) {
if outPath == "." || outPath == ".." ||
strings.HasPrefix(outPath, ".."+string(os.PathSeparator)) {
http.Error(w, "ungültiger output-pfad", http.StatusBadRequest)
return
}
}
fi, err := os.Stat(outPath)
if err != nil {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
if fi.IsDir() || fi.Size() == 0 {
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
return
}
img, err := extractLastFrameJPEG(outPath)
if err != nil {
// Fallback: erster Frame klappt bei “wachsenden” Dateien oft zuverlässiger
img2, err2 := extractFirstFrameJPEG(outPath)
if err2 != nil {
http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError)
return
}
img = img2
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(img)
}
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 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))
// LOW (ohne Audio spart Bandbreite)
lowArgs := append(commonIn,
"-vf", "scale=160:-2",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency",
"-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
"-an",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "4",
"-hls_flags", "delete_segments+append_list+independent_segments",
"-hls_segment_filename", filepath.Join(previewDir, "seg_low_%05d.ts"),
"-hls_base_url", baseURL,
filepath.Join(previewDir, "index.m3u8"),
)
// HQ (mit Audio)
hqArgs := append(commonIn,
"-vf", "scale=640:-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"),
)
// beide Prozesse starten (einfach & robust)
go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, "ffmpeg", 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()))
}
}("low", lowArgs)
go func(kind string, args []string) {
cmd := exec.CommandContext(ctx, "ffmpeg", 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(
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-i", path,
"-frames:v", "1",
"-q:v", "4",
"-f", "image2pipe",
"-vcodec", "mjpeg",
"pipe:1",
)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg first-frame: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
return out.Bytes(), nil
}
func resolvePathRelativeToExe(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", nil
}
// akzeptiere sowohl "records/done" als auch "records\done"
p = filepath.Clean(filepath.FromSlash(p))
if filepath.IsAbs(p) {
return p, nil
}
exe, err := os.Executable()
if err != nil {
return "", err
}
base := filepath.Dir(exe)
return filepath.Join(base, p), nil
}
// routes.go (package main)
func registerRoutes(mux *http.ServeMux) {
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)
modelsPath, _ := resolvePathRelativeToExe("data/models_store.json")
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)
}
// --- main ---
func main() {
loadSettings()
mux := http.NewServeMux()
registerRoutes(mux)
fmt.Println("🌐 HTTP-API aktiv: http://localhost:8080")
if err := http.ListenAndServe(":8080", 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"`
}
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
}
if req.URL == "" {
http.Error(w, "url fehlt", http.StatusBadRequest)
return
}
jobID := uuid.NewString()
ctx, cancel := context.WithCancel(context.Background())
job := &RecordJob{
ID: jobID,
SourceURL: req.URL,
Status: JobRunning,
StartedAt: time.Now(),
cancel: cancel,
}
jobsMu.Lock()
jobs[jobID] = job
jobsMu.Unlock()
go runJob(ctx, job, req)
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) {
defer func() {
now := time.Now()
job.EndedAt = &now
}()
hc := NewHTTPClient(req.UserAgent)
provider := detectProvider(req.URL)
var err error
now := time.Now()
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()
username := extractUsername(req.URL)
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(s.RecordDir, filename)
job.Output = outPath
err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job)
case "mfc":
s := getSettings()
username := extractMFCUsername(req.URL)
filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05"))
outPath := filepath.Join(s.RecordDir, filename)
job.Output = outPath
err = RecordStreamMFC(ctx, hc, username, outPath, job)
default:
err = errors.New("unsupported provider")
}
jobsMu.Lock()
defer jobsMu.Unlock()
if err != nil {
if errors.Is(err, context.Canceled) {
job.Status = JobStopped
// ✅ Auch bei STOP: .ts -> .mp4 remuxen (falls möglich)
if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
job.Output = newOut
}
// ✅ und danach nach "done" verschieben
if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
job.Output = moved
}
} else {
job.Status = JobFailed
job.Error = err.Error()
// ✅ best effort: trotzdem remuxen und nach done verschieben (falls Datei existiert)
if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
job.Output = newOut
}
if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
job.Output = moved
}
}
} else {
job.Status = JobFinished
// ✅ Erst remuxen (damit in /done direkt die .mp4 landet)
if newOut, err2 := maybeRemuxTS(job.Output); err2 == nil && newOut != "" {
job.Output = newOut
}
// ✅ nach "done" verschieben (robust)
if moved, err2 := moveToDoneDir(job.Output); err2 == nil && moved != "" {
job.Output = moved
}
}
}
func recordVideo(w http.ResponseWriter, r *http.Request) {
// ✅ NEU: Wiedergabe über Dateiname (für doneDir / recordDir)
if raw := r.URL.Query().Get("file"); strings.TrimSpace(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, _ := resolvePathRelativeToExe(s.RecordDir)
doneAbs, _ := resolvePathRelativeToExe(s.DoneDir)
candidates := []string{
filepath.Join(doneAbs, file), // bevorzugt doneDir
filepath.Join(recordAbs, file), // fallback recordDir
}
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
}
if ext == ".mp4" {
w.Header().Set("Content-Type", "video/mp4")
} else {
w.Header().Set("Content-Type", "video/mp2t")
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, outPath)
return
}
// --- ALT: Wiedergabe über Job-ID (funktioniert nur solange Job im RAM existiert) ---
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
}
outPath := filepath.Clean(strings.TrimSpace(job.Output))
if outPath == "" {
http.Error(w, "output fehlt", http.StatusNotFound)
return
}
if !filepath.IsAbs(outPath) {
abs, err := resolvePathRelativeToExe(outPath)
if err != nil {
http.Error(w, "pfad auflösung fehlgeschlagen", 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
}
ext := strings.ToLower(filepath.Ext(outPath))
if ext == ".mp4" {
w.Header().Set("Content-Type", "video/mp4")
} else if ext == ".ts" {
w.Header().Set("Content-Type", "video/mp2t")
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, outPath)
}
func recordDoneList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToExe(s.DoneDir)
if err != nil {
http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(doneAbs)
if err != nil {
http.Error(w, "doneDir lesen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
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
}
// ID stabil aus Dateiname (ohne Extension) reicht für Player/Key
base := strings.TrimSuffix(name, filepath.Ext(name))
// best effort: Zeiten aus FileInfo
t := fi.ModTime()
list = append(list, &RecordJob{
ID: base,
SourceURL: "",
Output: full,
Status: JobFinished,
StartedAt: t,
EndedAt: &t,
})
}
// neueste zuerst
sort.Slice(list, func(i, j int) bool {
return list[i].EndedAt.After(*list[j].EndedAt)
})
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(list)
}
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)
}
}
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 := resolvePathRelativeToExe(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
}
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]
jobsMu.Unlock()
if !ok {
http.Error(w, "job nicht gefunden", http.StatusNotFound)
return
}
if job.previewCmd != nil && job.previewCmd.Process != nil {
_ = job.previewCmd.Process.Kill()
job.previewCmd = nil
}
if job.cancel != nil {
job.cancel()
}
out := job.Output
if strings.EqualFold(filepath.Ext(out), ".ts") {
mp4 := strings.TrimSuffix(out, filepath.Ext(out)) + ".mp4"
if err := remuxTSToMP4(out, mp4); err == nil {
_ = os.Remove(out) // optional: TS löschen
job.Output = mp4 // wichtig: Output umstellen
} else {
// optional: loggen, TS behalten
}
}
fmt.Println("📡 Aufnahme gestoppt:", job.ID)
w.Write([]byte(`{"ok":"stopped"}`))
}
// --- 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)
}
}
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
// 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 = 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,
"ffmpeg",
"-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
}