554 lines
12 KiB
Go
554 lines
12 KiB
Go
// 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"
|
||
}
|