nsfwapp/backend/record_stream_cb.go
2026-04-07 07:20:08 +02:00

554 lines
12 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.

// backend\record_stream_cb.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/grafov/m3u8"
)
// RecordStream für Chaturbate:
// bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist.
// Stattdessen wie in der alten, funktionierenden Version:
// - Seite laden
// - HLS URL parsen
// - beste Variant-Playlist auswählen
// - Segmente selbst laden und in .ts schreiben
func RecordStream(
ctx context.Context,
hc *HTTPClient,
domain string,
username string,
outputPath string,
httpCookie string,
job *RecordJob,
) error {
username = strings.Trim(strings.TrimSpace(username), "/")
if username == "" {
return errors.New("leerer username")
}
base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username + "/"
loadFreshPlaylist := func() (*cbPlaylist, error) {
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
if err != nil {
return nil, fmt.Errorf("seite laden: %w", err)
}
hlsURL, err := ParseStream(body)
if err != nil {
return nil, fmt.Errorf("stream-parsing: %w", err)
}
hlsURL = strings.TrimSpace(hlsURL)
if hlsURL == "" {
return nil, errors.New("leere hls url")
}
finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders(
ctx,
hlsURL,
httpCookie,
hc.userAgent,
pageURL,
)
if err != nil {
return nil, fmt.Errorf("variant-playlist: %w", err)
}
finalPlaylistURL = strings.TrimSpace(finalPlaylistURL)
if finalPlaylistURL == "" {
return nil, errors.New("leere final playlist url")
}
return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie)
}
playlist, err := loadFreshPlaylist()
if err != nil {
return err
}
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
_, _ = ensureGeneratedDir(assetID)
}
jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
jobsMu.Unlock()
if assetID != "" {
startLiveThumbJPGLoop(ctx, job)
}
}
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
var written int64
var lastPush time.Time
var lastBytes int64
published := false
handleSegment := func(b []byte, duration float64) error {
if len(b) == 0 {
return nil
}
if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err)
}
_ = file.Sync()
if job != nil && !published {
published = true
_ = publishJob(job.ID)
}
written += int64(len(b))
if job != nil {
now := time.Now()
if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 {
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
_ = publishJob(job.ID)
lastPush = now
lastBytes = written
}
}
_ = duration
return nil
}
const maxPlaylistRefreshes = 12
refreshes := 0
for {
err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment)
if err == nil {
break
}
if ctx.Err() != nil {
return ctx.Err()
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
shouldRefresh :=
strings.Contains(msg, "403") ||
strings.Contains(msg, "401") ||
strings.Contains(msg, "playlist nicht mehr erreichbar") ||
strings.Contains(msg, "fehlerhafte playlist") ||
strings.Contains(msg, "keine neuen hls-segmente") ||
strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "http ")
if !shouldRefresh {
return fmt.Errorf("watch segments: %w", err)
}
refreshes++
if refreshes > maxPlaylistRefreshes {
return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err)
}
fmt.Println("🔄 [cb] refresh playlist:",
"user=", username,
"try=", refreshes,
"reason=", err,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1500 * time.Millisecond):
}
newPlaylist, rerr := loadFreshPlaylist()
if rerr != nil {
fmt.Println("⚠️ [cb] refresh failed:",
"user=", username,
"try=", refreshes,
"err=", rerr,
)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
playlist = newPlaylist
if job != nil {
jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
jobsMu.Unlock()
}
}
if job != nil {
jobsMu.Lock()
job.SizeBytes = written
jobsMu.Unlock()
_ = publishJob(job.ID)
}
return nil
}
// 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")
}
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
}
// eigener Typ nur für Chaturbate, damit es keinen Konflikt mit main.go gibt
type cbPlaylist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
func cbFetchPlaylist(ctx context.Context, hc *HTTPClient, playlistURL string, httpCookie string) (*cbPlaylist, error) {
req, err := hc.NewRequest(ctx, http.MethodGet, playlistURL, httpCookie)
if err != nil {
return nil, fmt.Errorf("playlist request erzeugen: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
return nil, fmt.Errorf("playlist request senden: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil {
return nil, fmt.Errorf("m3u8 parse: %w", err)
}
if listType == m3u8.MEDIA {
root := playlistURL
if idx := strings.LastIndex(root, "/"); idx >= 0 {
root = root[:idx+1]
}
return &cbPlaylist{
PlaylistURL: playlistURL,
RootURL: root,
}, nil
}
master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil {
return nil, errors.New("unerwarteter playlist-typ")
}
var bestURI string
var bestHeight int
var bestFramerate float64
for _, v := range master.Variants {
if v == nil || strings.TrimSpace(v.URI) == "" {
continue
}
h := 0
if v.Resolution != "" {
parts := strings.Split(v.Resolution, "x")
if len(parts) == 2 {
if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil {
h = hh
}
}
}
fr := 30.0
if v.FrameRate > 0 {
fr = v.FrameRate
}
if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) {
bestURI = strings.TrimSpace(v.URI)
bestHeight = h
bestFramerate = fr
}
}
if bestURI == "" {
return nil, errors.New("Master-Playlist ohne gültige Varianten")
}
baseURL, err := url.Parse(playlistURL)
if err != nil {
return nil, err
}
refURL, err := url.Parse(bestURI)
if err != nil {
return nil, err
}
finalURL := baseURL.ResolveReference(refURL).String()
root := finalURL
if idx := strings.LastIndex(root, "/"); idx >= 0 {
root = root[:idx+1]
}
return &cbPlaylist{
PlaylistURL: finalURL,
RootURL: root,
Resolution: bestHeight,
Framerate: int(bestFramerate),
}, nil
}
func (p *cbPlaylist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 60
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist request erzeugen: %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
segURL := segment.URI
if !strings.HasPrefix(segURL, "http://") && !strings.HasPrefix(segURL, "https://") {
segURL = p.RootURL + strings.TrimLeft(segment.URI, "/")
}
segReq, err := hc.NewRequest(ctx, http.MethodGet, segURL, httpCookie)
if err != nil {
continue
}
segResp, err := hc.client.Do(segReq)
if err != nil {
continue
}
if segResp.StatusCode != http.StatusOK {
segResp.Body.Close()
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)
}
}
// Cookie-Hilfsfunktion
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,
})
}
}
// helper
func extractUsername(input string) string {
s := strings.TrimSpace(input)
if s == "" {
return ""
}
if u, err := url.Parse(s); err == nil && u != nil {
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if strings.Contains(host, "chaturbate.com") {
p := strings.Trim(u.Path, "/")
if p == "" {
return ""
}
parts := strings.Split(p, "/")
return strings.TrimSpace(parts[0])
}
}
s = strings.TrimPrefix(s, "https://")
s = strings.TrimPrefix(s, "http://")
s = strings.TrimPrefix(s, "www.")
if strings.HasPrefix(strings.ToLower(s), "chaturbate.com/") {
s = s[len("chaturbate.com/"):]
}
if idx := strings.IndexAny(s, "/?#"); idx != -1 {
s = s[:idx]
}
return strings.Trim(s, "/\\")
}
func hasChaturbateCookies(cookieStr string) bool {
m := parseCookieString(cookieStr)
_, hasCF := m["cf_clearance"]
_, hasSessID := m["session_id"]
_, hasSessIdAlt := m["sessionid"]
return hasCF && (hasSessID || hasSessIdAlt)
}
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 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"
}