623 lines
14 KiB
Go
623 lines
14 KiB
Go
// backend\record_stream_cb.go
|
||
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/grafov/m3u8"
|
||
)
|
||
|
||
func previewSegmentsDir(previewDir string) string {
|
||
previewDir = strings.TrimSpace(previewDir)
|
||
if previewDir == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(previewDir, "segments")
|
||
}
|
||
|
||
func ensurePreviewSegmentsDir(previewDir string) (string, error) {
|
||
dir := previewSegmentsDir(previewDir)
|
||
if strings.TrimSpace(dir) == "" {
|
||
return "", fmt.Errorf("previewDir leer")
|
||
}
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
return dir, nil
|
||
}
|
||
|
||
// --- 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 {
|
||
base := strings.TrimRight(domain, "/")
|
||
pageURL := base + "/" + username
|
||
|
||
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
|
||
if err != nil {
|
||
return fmt.Errorf("seite laden: %w", err)
|
||
}
|
||
|
||
hlsURL, err := ParseStream(body)
|
||
if err != nil {
|
||
return fmt.Errorf("stream-parsing: %w", err)
|
||
}
|
||
|
||
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
|
||
if err != nil {
|
||
return fmt.Errorf("playlist abrufen: %w", err)
|
||
}
|
||
|
||
var previewDir string
|
||
var segmentsDir string
|
||
|
||
if job != nil {
|
||
assetID := assetIDForJob(job)
|
||
if assetID != "" {
|
||
if dir, derr := ensureGeneratedDir(assetID); derr == nil {
|
||
previewDir = strings.TrimSpace(dir)
|
||
|
||
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
|
||
segmentsDir = strings.TrimSpace(sdir)
|
||
}
|
||
}
|
||
}
|
||
|
||
jobsMu.Lock()
|
||
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
|
||
job.PreviewCookie = httpCookie
|
||
job.PreviewUA = hc.userAgent
|
||
if previewDir != "" {
|
||
job.PreviewDir = previewDir
|
||
}
|
||
jobsMu.Unlock()
|
||
|
||
if previewDir != "" {
|
||
startLiveThumbJPGLoop(ctx, job)
|
||
}
|
||
}
|
||
|
||
if segmentsDir != "" {
|
||
defer func() {
|
||
if err := os.RemoveAll(segmentsDir); err != nil {
|
||
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
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
|
||
|
||
trimPreviewSegments := func(dir string, keep int) {
|
||
if strings.TrimSpace(dir) == "" || keep <= 0 {
|
||
return
|
||
}
|
||
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
type item struct {
|
||
name string
|
||
mod time.Time
|
||
}
|
||
|
||
items := make([]item, 0, len(entries))
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
|
||
name := e.Name()
|
||
lower := strings.ToLower(name)
|
||
|
||
if lower == "init.mp4" {
|
||
continue
|
||
}
|
||
if !strings.HasPrefix(lower, "seg_preview_") {
|
||
continue
|
||
}
|
||
|
||
info, err := e.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
|
||
items = append(items, item{
|
||
name: name,
|
||
mod: info.ModTime(),
|
||
})
|
||
}
|
||
|
||
if len(items) <= keep {
|
||
return
|
||
}
|
||
|
||
sort.Slice(items, func(i, j int) bool {
|
||
return items[i].mod.Before(items[j].mod)
|
||
})
|
||
|
||
for i := 0; i < len(items)-keep; i++ {
|
||
_ = os.Remove(filepath.Join(dir, items[i].name))
|
||
}
|
||
}
|
||
|
||
err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, 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)
|
||
}
|
||
|
||
if err := file.Sync(); err != nil {
|
||
//fmt.Println("output sync failed:", outputPath, err)
|
||
}
|
||
|
||
if segmentsDir != "" {
|
||
if err := os.MkdirAll(segmentsDir, 0o755); err == nil {
|
||
ext := ".m4s"
|
||
if len(b) > 0 && b[0] == 0x47 {
|
||
ext = ".ts"
|
||
}
|
||
|
||
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
|
||
segPath := filepath.Join(segmentsDir, segName)
|
||
|
||
if err := os.WriteFile(segPath, b, 0o644); err == nil {
|
||
trimPreviewSegments(segmentsDir, 12)
|
||
}
|
||
}
|
||
}
|
||
|
||
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()
|
||
|
||
lastPush = now
|
||
lastBytes = written
|
||
}
|
||
}
|
||
|
||
_ = duration
|
||
_ = segmentURL
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("watch segments: %w", err)
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// nutzt ebenfalls *HTTPClient
|
||
func (p *Playlist) WatchSegments(
|
||
ctx context.Context,
|
||
hc *HTTPClient,
|
||
httpCookie string,
|
||
previewDir string,
|
||
handler func(segmentURL string, b []byte, duration float64) error,
|
||
) error {
|
||
var lastSeq int64 = -1
|
||
emptyRounds := 0
|
||
const maxEmptyRounds = 60
|
||
|
||
playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL))
|
||
if err != nil || playlistBaseURL == nil {
|
||
return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL)
|
||
}
|
||
|
||
var lastMapURL string
|
||
|
||
writeInitIfNeeded := func(mapURL string) {
|
||
if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" {
|
||
return
|
||
}
|
||
|
||
segDir, err := ensurePreviewSegmentsDir(previewDir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
initPath := filepath.Join(segDir, "init.mp4")
|
||
|
||
// schon vorhanden und gleiches URL-Mapping -> nichts tun
|
||
if mapURL == lastMapURL {
|
||
if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 {
|
||
return
|
||
}
|
||
}
|
||
|
||
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
resp, err := hc.client.Do(req)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
return
|
||
}
|
||
|
||
b, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return
|
||
}
|
||
if len(b) == 0 {
|
||
return
|
||
}
|
||
|
||
if err := os.WriteFile(initPath, b, 0o644); err != nil {
|
||
return
|
||
}
|
||
|
||
lastMapURL = mapURL
|
||
}
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
|
||
if strings.TrimSpace(p.PlaylistURL) == "" {
|
||
return errors.New("playlist url fehlt")
|
||
}
|
||
|
||
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++
|
||
//fmt.Println("playlist download failed:", p.PlaylistURL, err)
|
||
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline")
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
continue
|
||
}
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
//fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL)
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
|
||
emptyRounds++
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return fmt.Errorf("❌ playlist liefert status %d – stream vermutlich offline", resp.StatusCode)
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
continue
|
||
}
|
||
|
||
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
|
||
resp.Body.Close()
|
||
if err != nil {
|
||
emptyRounds++
|
||
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
continue
|
||
}
|
||
|
||
if listType != m3u8.MEDIA {
|
||
emptyRounds++
|
||
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline")
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
continue
|
||
}
|
||
|
||
media, ok := playlist.(*m3u8.MediaPlaylist)
|
||
if !ok || media == nil {
|
||
emptyRounds++
|
||
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("❌ Fehlerhafte Media-Playlist – möglicherweise offline")
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
continue
|
||
}
|
||
|
||
if media.Map != nil {
|
||
mapURI := strings.TrimSpace(media.Map.URI)
|
||
if mapURI != "" {
|
||
mapRef, err := url.Parse(mapURI)
|
||
if err != nil {
|
||
//fmt.Println("map uri parse failed:", mapURI, err)
|
||
} else {
|
||
mapURL := playlistBaseURL.ResolveReference(mapRef).String()
|
||
writeInitIfNeeded(mapURL)
|
||
}
|
||
}
|
||
}
|
||
|
||
newSegment := false
|
||
successfulSegment := false
|
||
|
||
for _, segment := range media.Segments {
|
||
if segment == nil {
|
||
continue
|
||
}
|
||
|
||
if int64(segment.SeqId) <= lastSeq {
|
||
continue
|
||
}
|
||
|
||
lastSeq = int64(segment.SeqId)
|
||
newSegment = true
|
||
|
||
segURI := strings.TrimSpace(segment.URI)
|
||
if segURI == "" {
|
||
//fmt.Println("segment uri empty in playlist:", p.PlaylistURL)
|
||
continue
|
||
}
|
||
|
||
segRef, err := url.Parse(segURI)
|
||
if err != nil {
|
||
//fmt.Println("segment uri parse failed:", segURI, err)
|
||
continue
|
||
}
|
||
|
||
segmentURL := playlistBaseURL.ResolveReference(segRef).String()
|
||
|
||
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
|
||
if err != nil {
|
||
//fmt.Println("segment request build failed:", segmentURL, err)
|
||
continue
|
||
}
|
||
|
||
segResp, err := hc.client.Do(segReq)
|
||
if err != nil {
|
||
//fmt.Println("segment download failed:", segmentURL, err)
|
||
continue
|
||
}
|
||
|
||
if segResp.StatusCode < 200 || segResp.StatusCode >= 300 {
|
||
//fmt.Println("segment bad status:", segResp.StatusCode, segmentURL)
|
||
_, _ = io.Copy(io.Discard, segResp.Body)
|
||
segResp.Body.Close()
|
||
continue
|
||
}
|
||
|
||
data, err := io.ReadAll(segResp.Body)
|
||
segResp.Body.Close()
|
||
if err != nil {
|
||
//fmt.Println("segment read failed:", segmentURL, err)
|
||
continue
|
||
}
|
||
if len(data) == 0 {
|
||
//fmt.Println("segment empty:", segmentURL)
|
||
continue
|
||
}
|
||
|
||
if err := handler(segmentURL, data, segment.Duration); err != nil {
|
||
return err
|
||
}
|
||
|
||
successfulSegment = true
|
||
}
|
||
|
||
if successfulSegment {
|
||
emptyRounds = 0
|
||
} else if newSegment {
|
||
emptyRounds++
|
||
//fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL)
|
||
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden – Stream vermutlich geschützt, fehlerhaft oder offline.")
|
||
}
|
||
} else {
|
||
emptyRounds++
|
||
if emptyRounds >= maxEmptyRounds {
|
||
return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.")
|
||
}
|
||
}
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(1 * time.Second):
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
})
|
||
}
|
||
}
|
||
|
||
// --- 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, "/\\")
|
||
}
|
||
|
||
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 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"
|
||
}
|