streamrecorder/main.go
2025-11-13 13:57:56 +01:00

785 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/grafov/m3u8"
)
/* ───────────────────────────────
Gemeinsame Typen & Utilities
─────────────────────────────── */
var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`)
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"
}
}
/* ───────────────────────────────
main
─────────────────────────────── */
func main() {
if len(os.Args) < 2 {
fmt.Println("Verwendung:")
fmt.Println(" recorder.exe <Chaturbate-URL oder Benutzername> [-o <zieldatei.ts>]")
fmt.Println(" recorder.exe <MyFreeCams-URL wie https://www.myfreecams.com/#name> [-o <zieldatei.mp4>]")
fmt.Println(" recorder.exe --site mfc <username> [-o <zieldatei.mp4>]")
os.Exit(1)
}
// optionales -o
outputPathArg := ""
site := "" // "", "mfc"
args := os.Args[1:]
clean := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
if args[i] == "-o" && i+1 < len(args) {
outputPathArg = args[i+1]
i++
continue
}
if args[i] == "--site" && i+1 < len(args) {
site = strings.ToLower(args[i+1])
i++
continue
}
clean = append(clean, args[i])
}
if len(clean) == 0 {
fmt.Println("Fehlendes Ziel (Benutzername oder URL).")
os.Exit(1)
}
arg := clean[0]
// Dispatch: MyFreeCams?
if site == "mfc" || strings.Contains(arg, "myfreecams.com/#") {
username := extractMFCUsername(arg)
if username == "" {
fmt.Println("Konnte MFC-Username nicht erkennen.")
os.Exit(1)
}
if err := runMFC(username, outputPathArg); err != nil {
fmt.Println("❌ MFC-Fehler:", err)
os.Exit(1)
}
return
}
// Sonst: Chaturbate (bestehender Flow) :contentReference[oaicite:1]{index=1}
username := extractCBUsername(arg)
outputPath := outputPathArg
if outputPath == "" {
outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405"))
}
ctx := context.Background()
body, err := fetchPage("https://chaturbate.com/" + username)
if err != nil {
fmt.Println("❌ Fehler beim Laden der Seite:", err)
os.Exit(1)
}
hlsURL, err := ParseStream(body)
if err != nil {
fmt.Println("❌ Stream nicht gefunden oder offline:", err)
os.Exit(1)
}
playlist, err := FetchPlaylist(ctx, hlsURL)
if err != nil {
fmt.Println("❌ Fehler beim Abrufen der Playlist:", err)
os.Exit(1)
}
file, err := os.Create(outputPath)
if err != nil {
fmt.Println("❌ Datei konnte nicht erstellt werden:", err)
os.Exit(1)
}
defer file.Close()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
err = playlist.WatchSegments(ctx, func(b []byte, _ float64) error {
_, err := file.Write(b)
if err != nil {
fmt.Println("❌ Fehler beim Schreiben in Datei:", err)
}
return err
})
if err != nil {
fmt.Println("❌ Aufnahmefehler:", err)
}
mp4Out := outputPath
ext := filepath.Ext(outputPath)
if ext != ".mp4" {
mp4Out = strings.TrimSuffix(outputPath, ext) + ".mp4"
}
if err := exec.Command(
"ffmpeg",
"-y",
"-i", outputPath,
"-c:v", "copy",
"-c:a", "copy",
"-bsf:a", "aac_adtstoasc",
"-movflags", "+faststart",
mp4Out,
).Run(); err != nil {
fmt.Println("⚠️ Fehler bei Umwandlung in MP4:", err)
} else {
fmt.Println("✅ Umwandlung abgeschlossen (web-optimiert):", mp4Out)
}
}
/* ───────────────────────────────
MyFreeCams (Port von myfreecams.py)
─────────────────────────────── */
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(username string, outArg string) error {
mfc := NewMyFreeCams(username)
st, err := mfc.GetStatus()
if err != nil {
return err
}
fmt.Println("MFC Status:", st)
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(m3u8URL, outArg)
}
/* ───────────────────────────────
Gemeinsame HLS/M3U8-Helper
─────────────────────────────── */
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" uns ist Höhe egal, nehmen die zweite Zahl als "Breite" (wie in deiner CB-Logik gemacht).
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(m3u8URL, outArg string) error {
// Minimalvalidierung
u, err := url.Parse(m3u8URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige URL: %q", m3u8URL)
}
// Erreichbarkeit kurz prüfen
resp, err := http.Get(m3u8URL)
if err != nil {
return fmt.Errorf("Abruf fehlgeschlagen: %w", 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)
}
// Ausgabedatei bestimmen
outFile := strings.TrimSpace(outArg)
if outFile == "" {
def := "mfc_" + time.Now().Format("20060102_150405") + ".mp4"
fmt.Printf("Name der MP4-Datei (Enter für %s): ", def)
if s := readLine(); s != "" {
outFile = s
} else {
outFile = def
}
}
// Überschreiben?
if fileExists(outFile) {
fmt.Print("Die Datei existiert bereits. Überschreiben? [y/N] ")
a := strings.ToLower(strings.TrimSpace(readLine()))
if a != "y" && a != "j" {
return errors.New("abgebrochen")
}
}
// ── 1) Live-fähiges, fragmentiertes MP4 schreiben (copy) ──────────
// WICHTIG: -bsf:a aac_adtstoasc setzen + explizit mappen
fmt.Println("📦 Starte Download mit ffmpeg:", outFile)
cmd := exec.Command(
"ffmpeg",
"-nostdin", "-y",
"-fflags", "+genpts",
"-i", m3u8URL,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "copy",
"-c:a", "copy",
"-bsf:a", "aac_adtstoasc",
"-movflags", "+frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4",
outFile,
)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err)
}
fmt.Println("✅ Download abgeschlossen:", outFile)
// ── 2) Finales Remux in +faststart (für blitzschnelles Öffnen) ────
fmt.Println("🔁 Remux in finales MP4 (faststart)…")
if err := remuxMP4Faststart(outFile); err != nil {
fmt.Println("⚠️ Remux-Fehler:", err)
} else {
fmt.Println("✅ Remux abgeschlossen:", outFile)
}
return nil
}
// remuxMP4Faststart remuxt ein (ggf. fragmentiertes) MP4 in finales MP4 mit +faststart.
// 1. Versuch: copy/copy. Wenn danach kein Audio vorhanden ist, 2. Versuch mit AAC-Reencode.
func remuxMP4Faststart(inPath string) error {
base := strings.TrimSuffix(inPath, filepath.Ext(inPath))
tmp := base + ".remux.mp4"
hadAudioBefore, _ := hasAudioTrack(inPath) // nur zur Info/Fallback
// 1) Copy-Remux
if err := runCmd("ffmpeg",
"-y",
"-i", inPath,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "copy",
"-c:a", "copy",
"-movflags", "+faststart",
tmp,
); err != nil {
return fmt.Errorf("ffmpeg remux (copy) fehlgeschlagen: %w", err)
}
hasAudioAfter, _ := hasAudioTrack(tmp)
if !hasAudioAfter && hadAudioBefore {
// 2) Fallback: remux mit Audio-Reencode (wem auch immer copy schiefging)
_ = os.Remove(tmp)
if err := runCmd("ffmpeg",
"-y",
"-i", inPath,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart",
tmp,
); err != nil {
return fmt.Errorf("ffmpeg remux (aac-fallback) fehlgeschlagen: %w", err)
}
}
// Original durch remuxtes ersetzen
if err := os.Remove(inPath); err != nil {
return fmt.Errorf("remux ok, aber konnte Original nicht entfernen: %w", err)
}
if err := os.Rename(tmp, inPath); err != nil {
return fmt.Errorf("remux ok, aber konnte Zieldatei nicht umbenennen: %w", err)
}
return nil
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// hasAudioTrack prüft mit ffprobe, ob mind. ein Audiostream vorhanden ist.
func hasAudioTrack(path string) (bool, error) {
// ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 <file>
out := &bytes.Buffer{}
cmd := exec.Command("ffprobe",
"-v", "error",
"-select_streams", "a",
"-show_entries", "stream=index",
"-of", "csv=p=0",
path,
)
cmd.Stdout = out
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return false, err
}
// Wenn irgendwas zurückkommt, gibt's Audio
return strings.TrimSpace(out.String()) != "", nil
}
/* ───────────────────────────────
Chaturbate-Helpers (aus deiner Datei)
─────────────────────────────── */
func extractCBUsername(input string) string { // :contentReference[oaicite:2]{index=2}
input = strings.TrimPrefix(input, "https://")
input = strings.TrimPrefix(input, "http://")
input = strings.TrimPrefix(input, "www.")
if strings.HasPrefix(input, "chaturbate.com/") {
return strings.TrimPrefix(input, "chaturbate.com/")
}
return strings.TrimSpace(input)
}
func fetchPage(u string) (string, error) { // :contentReference[oaicite:3]{index=3}
client := http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, u)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(data), nil
}
func ParseStream(html string) (string, error) { // :contentReference[oaicite:4]{index=4}
matches := roomDossierRegexp.FindStringSubmatch(html)
if len(matches) < 2 {
return "", errors.New("room dossier nicht gefunden")
}
encoded := matches[1]
decoded, err := strconv.Unquote(`"` + strings.ReplaceAll(encoded, `\\u`, `\u`) + `"`)
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
}
type Playlist struct { // :contentReference[oaicite:5]{index=5}
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
func FetchPlaylist(ctx context.Context, hlsSource string) (*Playlist, error) { // :contentReference[oaicite:6]{index=6}
if hlsSource == "" {
return nil, errors.New("HLS-URL leer")
}
resp, err := http.Get(hlsSource)
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
}
func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float64) error) error { // :contentReference[oaicite:7]{index=7}
var lastSeq int64 = -1
client := http.Client{Timeout: 10 * time.Second}
emptyRounds := 0
const maxEmptyRounds = 5
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
resp, err := client.Get(p.PlaylistURL)
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
segResp, err := client.Get(segmentURL)
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)
}
}
/* ───────────────────────────────
Kleine Helper
─────────────────────────────── */
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
}