434 lines
9.9 KiB
Go
434 lines
9.9 KiB
Go
// backend\record_stream_mfc.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/PuerkitoBio/goquery"
|
||
"github.com/grafov/m3u8"
|
||
)
|
||
|
||
// 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)
|
||
|
||
// ✅ Statt sofort zu failen: kurz auf PUBLIC warten
|
||
const waitPublicMax = 2 * time.Minute
|
||
deadline := time.Now().Add(waitPublicMax)
|
||
|
||
var lastSt *Status
|
||
|
||
for {
|
||
// Context cancel / stop
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
|
||
st, err := mfc.GetStatus()
|
||
if err == nil {
|
||
tmp := st
|
||
lastSt = &tmp
|
||
|
||
if st == StatusPublic {
|
||
break
|
||
}
|
||
}
|
||
|
||
if time.Now().After(deadline) {
|
||
if lastSt == nil {
|
||
return fmt.Errorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax)
|
||
}
|
||
return fmt.Errorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt)
|
||
|
||
}
|
||
|
||
time.Sleep(5 * time.Second)
|
||
}
|
||
|
||
// ✅ erst jetzt die Video URL holen (weil public)
|
||
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")
|
||
}
|
||
|
||
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
|
||
if job != nil {
|
||
jobsMu.Lock()
|
||
job.PreviewM3U8 = strings.TrimSpace(m3u8URL)
|
||
job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen
|
||
job.PreviewUA = hc.userAgent
|
||
jobsMu.Unlock()
|
||
}
|
||
|
||
// ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar)
|
||
if job != nil {
|
||
_ = publishJob(job.ID)
|
||
}
|
||
|
||
// Aufnahme starten
|
||
return handleM3U8Mode(ctx, m3u8URL, outputPath, job)
|
||
}
|
||
|
||
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, nil)
|
||
}
|
||
|
||
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, job *RecordJob) 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",
|
||
"-hide_banner",
|
||
"-nostats",
|
||
"-loglevel", "warning",
|
||
"-i", m3u8URL,
|
||
"-c", "copy",
|
||
outFile,
|
||
)
|
||
|
||
var stderr bytes.Buffer
|
||
cmd.Stdout = io.Discard
|
||
cmd.Stderr = &stderr
|
||
|
||
// ✅ live size polling während ffmpeg läuft
|
||
stopStat := make(chan struct{})
|
||
|
||
if job != nil {
|
||
go func() {
|
||
t := time.NewTicker(1 * time.Second)
|
||
defer t.Stop()
|
||
|
||
var last int64
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-stopStat:
|
||
return
|
||
case <-t.C:
|
||
fi, err := os.Stat(outFile)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
sz := fi.Size()
|
||
if sz > 0 && sz != last {
|
||
jobsMu.Lock()
|
||
job.SizeBytes = sz
|
||
jobsMu.Unlock()
|
||
publishJobUpsert(job)
|
||
last = sz
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
// ✅ WICHTIG: ffmpeg wirklich laufen lassen
|
||
err = cmd.Run()
|
||
|
||
close(stopStat)
|
||
|
||
if err != nil {
|
||
msg := strings.TrimSpace(stderr.String())
|
||
if msg != "" {
|
||
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
|
||
}
|
||
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
/* ───────────────────────────────
|
||
Kleine Helper für MFC
|
||
─────────────────────────────── */
|
||
|
||
func extractMFCUsername(input string) string {
|
||
s := strings.TrimSpace(input)
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
|
||
// 1) URL mit Fragment (#username)
|
||
if u, err := url.Parse(s); err == nil && u.Fragment != "" {
|
||
return strings.Trim(strings.TrimSpace(u.Fragment), "/")
|
||
}
|
||
|
||
// 2) URL Pfad: letztes Segment nehmen
|
||
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||
p := strings.Trim(u.Path, "/")
|
||
if p == "" {
|
||
return ""
|
||
}
|
||
parts := strings.Split(p, "/")
|
||
return strings.TrimSpace(parts[len(parts)-1])
|
||
}
|
||
|
||
// 3) Fallback: raw
|
||
return s
|
||
}
|