2943 lines
74 KiB
Go
2943 lines
74 KiB
Go
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"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"`
|
||
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"`
|
||
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:"-"`
|
||
|
||
cancel context.CancelFunc `json:"-"`
|
||
}
|
||
|
||
var (
|
||
jobs = map[string]*RecordJob{}
|
||
jobsMu = sync.Mutex{}
|
||
)
|
||
|
||
// ffmpeg-Binary suchen (env, neben EXE, oder PATH)
|
||
var ffmpegPath = detectFFmpegPath()
|
||
|
||
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 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()
|
||
|
||
// ffprobe (oder notfalls ffmpeg -i parsen)
|
||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||
"-v", "error",
|
||
"-show_entries", "format=duration",
|
||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||
path,
|
||
)
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
s := strings.TrimSpace(string(out))
|
||
sec, err := strconv.ParseFloat(s, 64)
|
||
if err != nil || sec <= 0 {
|
||
return 0, fmt.Errorf("invalid duration: %q", s)
|
||
}
|
||
|
||
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 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)
|
||
}
|
||
|
||
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)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(getSettings())
|
||
return
|
||
|
||
default:
|
||
http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
}
|
||
|
||
func settingsBrowse(w http.ResponseWriter, r *http.Request) {
|
||
target := r.URL.Query().Get("target")
|
||
if target != "record" && target != "done" && target != "ffmpeg" {
|
||
http.Error(w, "target muss record, done oder ffmpeg sein", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var (
|
||
p string
|
||
err error
|
||
)
|
||
|
||
if target == "ffmpeg" {
|
||
// Dateiauswahl für ffmpeg.exe
|
||
p, err = dialog.File().
|
||
Title("ffmpeg.exe auswählen").
|
||
Load()
|
||
} else {
|
||
// Ordnerauswahl für record/done
|
||
p, err = dialog.Directory().
|
||
Title("Ordner auswählen").
|
||
Browse()
|
||
}
|
||
|
||
if err != nil {
|
||
// User cancelled → 204 No Content ist praktisch fürs Frontend
|
||
if strings.Contains(strings.ToLower(err.Error()), "cancel") {
|
||
w.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
http.Error(w, "auswahl fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// optional: wenn innerhalb exe-dir, als RELATIV zurückgeben
|
||
p = maybeMakeRelativeToExe(p)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_ = json.NewEncoder(w).Encode(map[string]string{"path": p})
|
||
}
|
||
|
||
func maybeMakeRelativeToExe(abs string) string {
|
||
exe, err := os.Executable()
|
||
if err != nil {
|
||
return abs
|
||
}
|
||
base := filepath.Dir(exe)
|
||
|
||
rel, err := filepath.Rel(base, abs)
|
||
if err != nil {
|
||
return abs
|
||
}
|
||
// wenn rel mit ".." beginnt -> nicht innerhalb base -> absoluten Pfad behalten
|
||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||
return abs
|
||
}
|
||
return filepath.ToSlash(rel) // frontend-freundlich
|
||
}
|
||
|
||
// --- Gemeinsame Status-Werte für MFC ---
|
||
type Status int
|
||
|
||
const (
|
||
StatusUnknown Status = iota
|
||
StatusPublic
|
||
StatusPrivate
|
||
StatusOffline
|
||
StatusNotExist
|
||
)
|
||
|
||
func (s Status) String() string {
|
||
switch s {
|
||
case StatusPublic:
|
||
return "PUBLIC"
|
||
case StatusPrivate:
|
||
return "PRIVATE"
|
||
case StatusOffline:
|
||
return "OFFLINE"
|
||
case StatusNotExist:
|
||
return "NOTEXIST"
|
||
default:
|
||
return "UNKNOWN"
|
||
}
|
||
}
|
||
|
||
// HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR)
|
||
type HTTPClient struct {
|
||
client *http.Client
|
||
userAgent string
|
||
}
|
||
|
||
// gemeinsamen HTTP-Client erzeugen
|
||
func NewHTTPClient(userAgent string) *HTTPClient {
|
||
if userAgent == "" {
|
||
// Default, falls kein UA übergeben wird
|
||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
|
||
}
|
||
|
||
return &HTTPClient{
|
||
client: &http.Client{
|
||
Timeout: 10 * time.Second,
|
||
},
|
||
userAgent: userAgent,
|
||
}
|
||
}
|
||
|
||
// Request-Erstellung mit User-Agent + Cookies
|
||
func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) {
|
||
req, err := http.NewRequestWithContext(ctx, method, url, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Basis-Header, die immer gesetzt werden
|
||
if h.userAgent != "" {
|
||
req.Header.Set("User-Agent", h.userAgent)
|
||
} else {
|
||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||
}
|
||
req.Header.Set("Accept", "*/*")
|
||
|
||
// Cookie-String wie "name=value; foo=bar"
|
||
addCookiesFromString(req, cookieStr)
|
||
|
||
return req, nil
|
||
}
|
||
|
||
// Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate)
|
||
func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) {
|
||
req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
resp, err := h.client.Do(req)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
data, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
body := string(data)
|
||
|
||
// Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden"
|
||
if strings.Contains(body, "<title>Just a moment...</title>") {
|
||
return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") – kein Room-HTML")
|
||
}
|
||
if strings.Contains(body, "Verify your age") {
|
||
return "", errors.New("Altersverifikationsseite erhalten – kein Room-HTML")
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
|
||
}
|
||
|
||
return body, nil
|
||
}
|
||
|
||
func remuxTSToMP4(tsPath, mp4Path string) error {
|
||
// ffmpeg -y -i in.ts -c copy -movflags +faststart out.mp4
|
||
cmd := exec.Command(ffmpegPath,
|
||
"-y",
|
||
"-i", tsPath,
|
||
"-c", "copy",
|
||
"-movflags", "+faststart",
|
||
mp4Path,
|
||
)
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
return fmt.Errorf("ffmpeg remux failed: %v (%s)", err, stderr.String())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- MP4 Streaming Optimierung (Fast Start) ---
|
||
// "Fast Start" bedeutet: moov vor mdat (Browser kann sofort Metadaten lesen)
|
||
func isFastStartMP4(path string) (bool, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer f.Close()
|
||
|
||
for i := 0; i < 256; i++ {
|
||
var hdr [8]byte
|
||
if _, err := io.ReadFull(f, hdr[:]); err != nil {
|
||
// unklar/kurz -> nicht anfassen
|
||
return true, nil
|
||
}
|
||
|
||
sz32 := binary.BigEndian.Uint32(hdr[0:4])
|
||
typ := string(hdr[4:8])
|
||
|
||
var boxSize int64
|
||
headerSize := int64(8)
|
||
|
||
if sz32 == 0 {
|
||
return true, nil
|
||
}
|
||
if sz32 == 1 {
|
||
var ext [8]byte
|
||
if _, err := io.ReadFull(f, ext[:]); err != nil {
|
||
return true, nil
|
||
}
|
||
boxSize = int64(binary.BigEndian.Uint64(ext[:]))
|
||
headerSize = 16
|
||
} else {
|
||
boxSize = int64(sz32)
|
||
}
|
||
|
||
if boxSize < headerSize {
|
||
return true, nil
|
||
}
|
||
|
||
switch typ {
|
||
case "moov":
|
||
return true, nil
|
||
case "mdat":
|
||
return false, nil
|
||
}
|
||
|
||
if _, err := f.Seek(boxSize-headerSize, io.SeekCurrent); err != nil {
|
||
return true, nil
|
||
}
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
func ensureFastStartMP4(path string) error {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" || !strings.EqualFold(filepath.Ext(path), ".mp4") {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(ffmpegPath) == "" {
|
||
return nil
|
||
}
|
||
|
||
ok, err := isFastStartMP4(path)
|
||
if err == nil && ok {
|
||
return nil
|
||
}
|
||
|
||
dir := filepath.Dir(path)
|
||
base := filepath.Base(path)
|
||
tmp := filepath.Join(dir, ".__faststart__"+base+".tmp")
|
||
bak := filepath.Join(dir, ".__faststart__"+base+".bak")
|
||
|
||
_ = os.Remove(tmp)
|
||
_ = os.Remove(bak)
|
||
|
||
cmd := exec.Command(ffmpegPath,
|
||
"-y",
|
||
"-i", path,
|
||
"-c", "copy",
|
||
"-movflags", "+faststart",
|
||
tmp,
|
||
)
|
||
|
||
var stderr bytes.Buffer
|
||
cmd.Stderr = &stderr
|
||
if err := cmd.Run(); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("ffmpeg faststart failed: %v (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
|
||
// atomar austauschen
|
||
if err := os.Rename(path, bak); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("rename original to bak failed: %w", err)
|
||
}
|
||
if err := os.Rename(tmp, path); err != nil {
|
||
_ = os.Rename(bak, path)
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("rename tmp to original failed: %w", err)
|
||
}
|
||
_ = os.Remove(bak)
|
||
return nil
|
||
}
|
||
|
||
func extractLastFrameJPEG(path string) ([]byte, error) {
|
||
cmd := exec.Command(
|
||
ffmpegPath,
|
||
"-hide_banner",
|
||
"-loglevel", "error",
|
||
"-sseof", "-0.1",
|
||
"-i", path,
|
||
"-frames:v", "1",
|
||
"-vf", "scale=320:-2",
|
||
"-q:v", "7",
|
||
"-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=320:-2",
|
||
"-q:v", "7",
|
||
"-f", "image2pipe",
|
||
"-vcodec", "mjpeg",
|
||
"pipe:1",
|
||
)
|
||
|
||
var out bytes.Buffer
|
||
var stderr bytes.Buffer
|
||
cmd.Stdout = &out
|
||
cmd.Stderr = &stderr
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
return nil, fmt.Errorf("ffmpeg frame-at-time: %w (%s)", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
return out.Bytes(), nil
|
||
}
|
||
|
||
// sucht das "neueste" Preview-Segment (seg_low_XXXXX.ts / seg_hq_XXXXX.ts)
|
||
func latestPreviewSegment(previewDir string) (string, error) {
|
||
entries, err := os.ReadDir(previewDir)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
var best string
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
name := e.Name()
|
||
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") {
|
||
continue
|
||
}
|
||
if best == "" || name > best {
|
||
best = name
|
||
}
|
||
}
|
||
|
||
if best == "" {
|
||
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir)
|
||
}
|
||
return filepath.Join(previewDir, best), nil
|
||
}
|
||
|
||
// erzeugt ein JPEG aus dem letzten Preview-Segment
|
||
func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) {
|
||
seg, err := latestPreviewSegment(previewDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Segment ist klein und "fertig" – hier reicht ein Last-Frame-Versuch,
|
||
// mit Fallback auf First-Frame.
|
||
img, err := extractLastFrameJPEG(seg)
|
||
if err != nil {
|
||
return extractFirstFrameJPEG(seg)
|
||
}
|
||
return img, nil
|
||
}
|
||
|
||
func recordPreview(w http.ResponseWriter, r *http.Request) {
|
||
id := r.URL.Query().Get("id")
|
||
if id == "" {
|
||
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) {
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
http.Error(w, "id fehlt", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if strings.ContainsAny(id, `/\`) {
|
||
http.Error(w, "ungültige id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
s := getSettings()
|
||
recordAbs, _ := 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"),
|
||
}
|
||
|
||
var outPath string
|
||
for _, p := range candidates {
|
||
fi, err := os.Stat(p)
|
||
if err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||
outPath = p
|
||
break
|
||
}
|
||
}
|
||
if outPath == "" {
|
||
http.Error(w, "preview nicht verfügbar", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
previewDir := filepath.Join(os.TempDir(), "rec_preview", id)
|
||
if err := os.MkdirAll(previewDir, 0o755); err != nil {
|
||
http.Error(w, "preview-dir nicht verfügbar", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// ✅ Cleanup: hält Cache klein + entfernt .part
|
||
// Empfehlung: 250 Frames pro Video, max 14 Tage behalten
|
||
const maxFrames = 250
|
||
const maxAge = 14 * 24 * time.Hour
|
||
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
|
||
|
||
// ✅ Frame bei Zeitposition t + Disk-Cache
|
||
if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" {
|
||
if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 {
|
||
key := int(sec*10 + 0.5) // 0.1s Raster, gerundet
|
||
if key < 0 {
|
||
key = 0
|
||
}
|
||
cachedFramePath := filepath.Join(previewDir, fmt.Sprintf("t_%09d.jpg", key))
|
||
|
||
if fi, err := os.Stat(cachedFramePath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||
servePreviewJPEGFile(w, r, cachedFramePath)
|
||
return
|
||
}
|
||
|
||
actualSec := float64(key) / 10.0
|
||
if img, err := extractFrameAtTimeJPEG(outPath, actualSec); err == nil && len(img) > 0 {
|
||
tmp := cachedFramePath + ".part"
|
||
_ = os.WriteFile(tmp, img, 0o644)
|
||
_ = os.Rename(tmp, cachedFramePath)
|
||
|
||
// nach neuem Write einmal kurz pruning (optional, aber hält hartes Limit)
|
||
prunePreviewCacheDir(previewDir, maxFrames, maxAge)
|
||
|
||
servePreviewJPEGBytes(w, img)
|
||
return
|
||
}
|
||
// wenn ffmpeg scheitert -> unten statisches preview
|
||
}
|
||
}
|
||
|
||
// Statisches preview.jpg (Fallback, gecached)
|
||
jpegPath := filepath.Join(previewDir, "preview.jpg")
|
||
if fi, err := os.Stat(jpegPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||
servePreviewJPEGFile(w, r, jpegPath)
|
||
return
|
||
}
|
||
|
||
img, err := extractLastFrameJPEG(outPath)
|
||
if err != nil {
|
||
img2, err2 := extractFirstFrameJPEG(outPath)
|
||
if err2 != nil {
|
||
http.Error(w, "konnte preview nicht erzeugen: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
img = img2
|
||
}
|
||
|
||
tmp := jpegPath + ".part"
|
||
_ = os.WriteFile(tmp, img, 0o644)
|
||
_ = os.Rename(tmp, jpegPath)
|
||
|
||
servePreviewJPEGBytes(w, img)
|
||
}
|
||
|
||
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=320:-2",
|
||
"-q:v", "7",
|
||
"-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)
|
||
|
||
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) {
|
||
defer func() {
|
||
now := time.Now()
|
||
jobsMu.Lock()
|
||
defer jobsMu.Unlock()
|
||
|
||
job.EndedAt = &now
|
||
|
||
// ✅ "Dauer" = Laufzeit (Recording Runtime), nicht ffprobe
|
||
if job.StartedAt.After(time.Time{}) {
|
||
sec := now.Sub(job.StartedAt).Seconds()
|
||
if sec > 0 {
|
||
job.DurationSeconds = sec
|
||
}
|
||
}
|
||
}()
|
||
|
||
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) {
|
||
// ✅ 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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()
|
||
|
||
start := t
|
||
stem := base
|
||
if strings.HasPrefix(stem, "HOT ") {
|
||
stem = strings.TrimPrefix(stem, "HOT ")
|
||
}
|
||
if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil {
|
||
mm, _ := strconv.Atoi(m[2])
|
||
dd, _ := strconv.Atoi(m[3])
|
||
yy, _ := strconv.Atoi(m[4])
|
||
hh, _ := strconv.Atoi(m[5])
|
||
mi, _ := strconv.Atoi(m[6])
|
||
ss, _ := strconv.Atoi(m[7])
|
||
start = time.Date(yy, time.Month(mm), dd, hh, mi, ss, 0, time.Local)
|
||
}
|
||
|
||
dur := 0.0
|
||
if t.After(start) {
|
||
dur = t.Sub(start).Seconds()
|
||
}
|
||
|
||
list = append(list, &RecordJob{
|
||
ID: base,
|
||
Output: full,
|
||
Status: JobFinished,
|
||
StartedAt: start,
|
||
EndedAt: &t,
|
||
DurationSeconds: dur, // ✅ Runtime
|
||
SizeBytes: fi.Size(),
|
||
})
|
||
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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)
|
||
|
||
// 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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
if err := removeWithRetry(target); err != nil {
|
||
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
|
||
}
|
||
|
||
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)
|
||
|
||
// 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
|
||
}
|
||
|
||
keepDir := filepath.Join(doneAbs, "keep")
|
||
if err := os.MkdirAll(keepDir, 0o755); err != nil {
|
||
http.Error(w, "keep dir anlegen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
dst := filepath.Join(keepDir, file)
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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]
|
||
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
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 4) Datei öffnen
|
||
file, err := os.Create(outputPath)
|
||
if err != nil {
|
||
return fmt.Errorf("datei erstellen: %w", err)
|
||
}
|
||
defer func() {
|
||
_ = file.Close()
|
||
}()
|
||
|
||
// 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR
|
||
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
|
||
// Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben
|
||
if _, err := file.Write(b); err != nil {
|
||
return fmt.Errorf("schreibe segment: %w", err)
|
||
}
|
||
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
|
||
_ = duration // aktuell unbenutzt
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("watch segments: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf.
|
||
// In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren.
|
||
// RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC).
|
||
func RecordStreamMFC(
|
||
ctx context.Context,
|
||
hc *HTTPClient,
|
||
username string,
|
||
outputPath string,
|
||
job *RecordJob,
|
||
) error {
|
||
mfc := NewMyFreeCams(username)
|
||
|
||
// optional, aber sinnvoll: nur aufnehmen wenn Public
|
||
st, err := mfc.GetStatus()
|
||
if err != nil {
|
||
return fmt.Errorf("mfc status: %w", err)
|
||
}
|
||
if st != StatusPublic {
|
||
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
|
||
}
|
||
|
||
m3u8URL, err := mfc.GetVideoURL(false)
|
||
if err != nil {
|
||
return fmt.Errorf("mfc get video url: %w", err)
|
||
}
|
||
if strings.TrimSpace(m3u8URL) == "" {
|
||
return fmt.Errorf("mfc: keine m3u8 URL gefunden")
|
||
}
|
||
|
||
// ✅ Preview starten
|
||
if job != nil && job.PreviewDir == "" {
|
||
previewDir := filepath.Join(os.TempDir(), "preview_"+job.ID)
|
||
job.PreviewDir = previewDir
|
||
|
||
if err := startPreviewHLS(ctx, job.ID, m3u8URL, previewDir, "", hc.userAgent); err != nil {
|
||
fmt.Println("⚠️ preview start fehlgeschlagen:", err)
|
||
job.PreviewDir = "" // rollback
|
||
}
|
||
}
|
||
|
||
// Aufnahme starten
|
||
return handleM3U8Mode(ctx, m3u8URL, outputPath)
|
||
}
|
||
|
||
func detectProvider(raw string) string {
|
||
s := strings.ToLower(raw)
|
||
|
||
if strings.Contains(s, "chaturbate.com") {
|
||
return "chaturbate"
|
||
}
|
||
if strings.Contains(s, "myfreecams.com") {
|
||
return "mfc"
|
||
}
|
||
return "unknown"
|
||
}
|
||
|
||
// --- helper ---
|
||
func extractUsername(input string) string {
|
||
input = strings.TrimSpace(input)
|
||
input = strings.TrimPrefix(input, "https://")
|
||
input = strings.TrimPrefix(input, "http://")
|
||
input = strings.TrimPrefix(input, "www.")
|
||
if strings.HasPrefix(input, "chaturbate.com/") {
|
||
input = strings.TrimPrefix(input, "chaturbate.com/")
|
||
}
|
||
|
||
// alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.)
|
||
if idx := strings.IndexAny(input, "/?#"); idx != -1 {
|
||
input = input[:idx]
|
||
}
|
||
|
||
// zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen
|
||
return strings.Trim(input, "/\\")
|
||
}
|
||
|
||
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
|
||
func addCookiesFromString(req *http.Request, cookieStr string) {
|
||
if cookieStr == "" {
|
||
return
|
||
}
|
||
pairs := strings.Split(cookieStr, ";")
|
||
for _, pair := range pairs {
|
||
parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(parts[0])
|
||
value := strings.TrimSpace(parts[1])
|
||
if name == "" {
|
||
continue
|
||
}
|
||
req.AddCookie(&http.Cookie{
|
||
Name: name,
|
||
Value: value,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
|
||
func ParseStream(html string) (string, error) {
|
||
matches := roomDossierRegexp.FindStringSubmatch(html)
|
||
if len(matches) == 0 {
|
||
return "", errors.New("room dossier nicht gefunden")
|
||
}
|
||
|
||
// DVR-Style Unicode-Decode
|
||
decoded, err := strconv.Unquote(
|
||
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
|
||
)
|
||
if err != nil {
|
||
return "", fmt.Errorf("Unicode-decode failed: %w", err)
|
||
}
|
||
|
||
var rd struct {
|
||
HLSSource string `json:"hls_source"`
|
||
}
|
||
if err := json.Unmarshal([]byte(decoded), &rd); err != nil {
|
||
return "", fmt.Errorf("JSON-parse failed: %w", err)
|
||
}
|
||
if rd.HLSSource == "" {
|
||
return "", errors.New("kein HLS-Quell-URL im JSON")
|
||
}
|
||
return rd.HLSSource, nil
|
||
}
|
||
|
||
// --- Playlist/WatchSegments wie gehabt ---
|
||
type Playlist struct {
|
||
PlaylistURL string
|
||
RootURL string
|
||
Resolution int
|
||
Framerate int
|
||
}
|
||
|
||
type Resolution struct {
|
||
Framerate map[int]string
|
||
Width int
|
||
}
|
||
|
||
// nimmt jetzt *HTTPClient entgegen
|
||
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
|
||
if hlsSource == "" {
|
||
return nil, errors.New("HLS-URL leer")
|
||
}
|
||
|
||
req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
|
||
}
|
||
|
||
resp, err := hc.client.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||
if err != nil || listType != m3u8.MASTER {
|
||
return nil, errors.New("keine gültige Master-Playlist")
|
||
}
|
||
|
||
master := playlist.(*m3u8.MasterPlaylist)
|
||
var bestURI string
|
||
var bestWidth int
|
||
var bestFramerate int
|
||
|
||
for _, variant := range master.Variants {
|
||
if variant == nil || variant.Resolution == "" {
|
||
continue
|
||
}
|
||
|
||
parts := strings.Split(variant.Resolution, "x")
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
|
||
width, err := strconv.Atoi(parts[1])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
fr := 30
|
||
if strings.Contains(variant.Name, "FPS:60.0") {
|
||
fr = 60
|
||
}
|
||
|
||
if width > bestWidth || (width == bestWidth && fr > bestFramerate) {
|
||
bestWidth = width
|
||
bestFramerate = fr
|
||
bestURI = variant.URI
|
||
}
|
||
}
|
||
|
||
if bestURI == "" {
|
||
return nil, errors.New("keine gültige Auflösung gefunden")
|
||
}
|
||
|
||
root := hlsSource[:strings.LastIndex(hlsSource, "/")+1]
|
||
return &Playlist{
|
||
PlaylistURL: root + bestURI,
|
||
RootURL: root,
|
||
Resolution: bestWidth,
|
||
Framerate: bestFramerate,
|
||
}, nil
|
||
}
|
||
|
||
// nutzt ebenfalls *HTTPClient
|
||
func (p *Playlist) WatchSegments(
|
||
ctx context.Context,
|
||
hc *HTTPClient,
|
||
httpCookie string,
|
||
handler func([]byte, float64) error,
|
||
) error {
|
||
var lastSeq int64 = -1
|
||
emptyRounds := 0
|
||
const maxEmptyRounds = 60 // statt 5
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
|
||
// Playlist holen
|
||
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
|
||
if err != nil {
|
||
return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
|
||
}
|
||
|
||
resp, err := hc.client.Do(req)
|
||
if err != nil {
|
||
emptyRounds++
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline")
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
|
||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||
resp.Body.Close()
|
||
|
||
if err != nil || listType != m3u8.MEDIA {
|
||
emptyRounds++
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
|
||
media := playlist.(*m3u8.MediaPlaylist)
|
||
newSegment := false
|
||
|
||
for _, segment := range media.Segments {
|
||
if segment == nil {
|
||
continue
|
||
}
|
||
if int64(segment.SeqId) <= lastSeq {
|
||
continue
|
||
}
|
||
|
||
lastSeq = int64(segment.SeqId)
|
||
newSegment = true
|
||
|
||
segmentURL := p.RootURL + segment.URI
|
||
|
||
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
segResp, err := hc.client.Do(segReq)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
data, err := io.ReadAll(segResp.Body)
|
||
segResp.Body.Close()
|
||
if err != nil || len(data) == 0 {
|
||
continue
|
||
}
|
||
|
||
if err := handler(data, segment.Duration); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
if newSegment {
|
||
emptyRounds = 0
|
||
} else {
|
||
emptyRounds++
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.")
|
||
}
|
||
}
|
||
|
||
time.Sleep(1 * time.Second)
|
||
}
|
||
}
|
||
|
||
/* ───────────────────────────────
|
||
MyFreeCams (übernommener Flow)
|
||
─────────────────────────────── */
|
||
|
||
type MyFreeCams struct {
|
||
Username string
|
||
Attrs map[string]string
|
||
VideoURL string
|
||
}
|
||
|
||
func NewMyFreeCams(username string) *MyFreeCams {
|
||
return &MyFreeCams{
|
||
Username: username,
|
||
Attrs: map[string]string{},
|
||
}
|
||
}
|
||
|
||
func (m *MyFreeCams) GetWebsiteURL() string {
|
||
return "https://www.myfreecams.com/#" + m.Username
|
||
}
|
||
|
||
func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) {
|
||
if !refresh && m.VideoURL != "" {
|
||
return m.VideoURL, nil
|
||
}
|
||
|
||
// Prüfen, ob alle benötigten Attribute vorhanden sind
|
||
if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok {
|
||
return "", nil
|
||
}
|
||
sid := m.Attrs["data-cam-preview-server-id-value"]
|
||
midBase := m.Attrs["data-cam-preview-model-id-value"]
|
||
isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true"
|
||
|
||
midInt, err := strconv.Atoi(midBase)
|
||
if err != nil {
|
||
return "", fmt.Errorf("model-id parse error: %w", err)
|
||
}
|
||
mid := 100000000 + midInt
|
||
a := ""
|
||
if isWzobs {
|
||
a = "a_"
|
||
}
|
||
|
||
playlistURL := fmt.Sprintf(
|
||
"https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8",
|
||
sid, a, mid,
|
||
)
|
||
|
||
// Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken
|
||
u, err := getWantedResolutionPlaylist(playlistURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
m.VideoURL = u
|
||
return m.VideoURL, nil
|
||
}
|
||
|
||
func (m *MyFreeCams) GetStatus() (Status, error) {
|
||
// 1) share-Seite prüfen (existiert/nicht existiert)
|
||
shareURL := "https://share.myfreecams.com/" + m.Username
|
||
resp, err := http.Get(shareURL)
|
||
if err != nil {
|
||
return StatusUnknown, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode == 404 {
|
||
return StatusNotExist, nil
|
||
}
|
||
if resp.StatusCode != 200 {
|
||
return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
|
||
// wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML)
|
||
bodyBytes, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return StatusUnknown, err
|
||
}
|
||
|
||
// 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist
|
||
start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?"))
|
||
if start == -1 {
|
||
// ohne tracking Parameter -> behandeln wie nicht existent
|
||
return StatusNotExist, nil
|
||
}
|
||
end := bytes.IndexByte(bodyBytes[start:], '"')
|
||
if end == -1 {
|
||
return StatusUnknown, errors.New("tracking url parse failed")
|
||
}
|
||
raw := string(bodyBytes[start : start+end])
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err)
|
||
}
|
||
qs := u.Query()
|
||
if qs.Get("model_id") == "" {
|
||
return StatusNotExist, nil
|
||
}
|
||
|
||
// 3) HTML parsen und <div class="campreview" ...> Attribute auslesen
|
||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
|
||
if err != nil {
|
||
return StatusUnknown, err
|
||
}
|
||
|
||
params := doc.Find(".campreview").First()
|
||
if params.Length() == 0 {
|
||
// keine campreview -> offline
|
||
return StatusOffline, nil
|
||
}
|
||
|
||
attrs := map[string]string{}
|
||
params.Each(func(_ int, s *goquery.Selection) {
|
||
for _, a := range []string{
|
||
"data-cam-preview-server-id-value",
|
||
"data-cam-preview-model-id-value",
|
||
"data-cam-preview-is-wzobs-value",
|
||
} {
|
||
if v, ok := s.Attr(a); ok {
|
||
attrs[a] = v
|
||
}
|
||
}
|
||
})
|
||
m.Attrs = attrs
|
||
|
||
// 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln
|
||
uStr, err := m.GetVideoURL(true)
|
||
if err != nil {
|
||
return StatusUnknown, err
|
||
}
|
||
if uStr != "" {
|
||
return StatusPublic, nil
|
||
}
|
||
// campreview vorhanden, aber keine playable url -> „PRIVATE“
|
||
return StatusPrivate, nil
|
||
}
|
||
|
||
func runMFC(ctx context.Context, username string, outArg string) error {
|
||
mfc := NewMyFreeCams(username)
|
||
|
||
st, err := mfc.GetStatus()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if st != StatusPublic {
|
||
return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st)
|
||
}
|
||
|
||
m3u8URL, err := mfc.GetVideoURL(false)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if m3u8URL == "" {
|
||
return errors.New("keine m3u8 URL gefunden")
|
||
}
|
||
|
||
return handleM3U8Mode(ctx, m3u8URL, outArg)
|
||
}
|
||
|
||
/* ───────────────────────────────
|
||
Gemeinsame HLS/M3U8-Helper (MFC)
|
||
─────────────────────────────── */
|
||
|
||
func getWantedResolutionPlaylist(playlistURL string) (string, error) {
|
||
// Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück.
|
||
resp, err := http.Get(playlistURL)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != 200 {
|
||
return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
|
||
}
|
||
|
||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||
if err != nil {
|
||
return "", fmt.Errorf("m3u8 parse: %w", err)
|
||
}
|
||
if listType == m3u8.MEDIA {
|
||
return playlistURL, nil
|
||
}
|
||
|
||
master := playlist.(*m3u8.MasterPlaylist)
|
||
var bestURI string
|
||
var bestWidth int
|
||
var bestFramerate float64
|
||
|
||
for _, v := range master.Variants {
|
||
if v == nil {
|
||
continue
|
||
}
|
||
// Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert.
|
||
w := 0
|
||
if v.Resolution != "" {
|
||
parts := strings.Split(v.Resolution, "x")
|
||
if len(parts) == 2 {
|
||
if ww, err := strconv.Atoi(parts[1]); err == nil {
|
||
w = ww
|
||
}
|
||
}
|
||
}
|
||
fr := 30.0
|
||
if v.FrameRate > 0 {
|
||
fr = v.FrameRate
|
||
} else if strings.Contains(v.Name, "FPS:60") {
|
||
fr = 60
|
||
}
|
||
if w > bestWidth || (w == bestWidth && fr > bestFramerate) {
|
||
bestWidth = w
|
||
bestFramerate = fr
|
||
bestURI = v.URI
|
||
}
|
||
}
|
||
if bestURI == "" {
|
||
return "", errors.New("Master-Playlist ohne gültige Varianten")
|
||
}
|
||
|
||
// Absolutieren
|
||
root := playlistURL[:strings.LastIndex(playlistURL, "/")+1]
|
||
if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") {
|
||
return bestURI, nil
|
||
}
|
||
return root + bestURI, nil
|
||
}
|
||
|
||
func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string) error {
|
||
// Validierung
|
||
u, err := url.Parse(m3u8URL)
|
||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||
return fmt.Errorf("ungültige URL: %q", m3u8URL)
|
||
}
|
||
|
||
// HTTP-Check MIT Context
|
||
req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
|
||
if resp.StatusCode != 200 {
|
||
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
|
||
}
|
||
|
||
if strings.TrimSpace(outFile) == "" {
|
||
return errors.New("output file path leer")
|
||
}
|
||
|
||
// ffmpeg mit Context (STOP FUNKTIONIERT HIER!)
|
||
cmd := exec.CommandContext(
|
||
ctx,
|
||
ffmpegPath,
|
||
"-y",
|
||
"-i", m3u8URL,
|
||
"-c", "copy",
|
||
outFile,
|
||
)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
|
||
if err := cmd.Run(); err != nil {
|
||
if errors.Is(ctx.Err(), context.Canceled) {
|
||
return ctx.Err()
|
||
}
|
||
return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
/* ───────────────────────────────
|
||
Kleine Helper für MFC
|
||
─────────────────────────────── */
|
||
|
||
func extractMFCUsername(input string) string {
|
||
if strings.Contains(input, "myfreecams.com/#") {
|
||
i := strings.Index(input, "#")
|
||
if i >= 0 && i < len(input)-1 {
|
||
return strings.TrimSpace(input[i+1:])
|
||
}
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(input)
|
||
}
|
||
|
||
func readLine() string {
|
||
r := bufio.NewReader(os.Stdin)
|
||
s, _ := r.ReadString('\n')
|
||
return strings.TrimRight(s, "\r\n")
|
||
}
|
||
|
||
func fileExists(path string) bool {
|
||
_, err := os.Stat(path)
|
||
return err == nil
|
||
}
|