nsfwapp/backend/record_stream_cb.go
2026-04-01 09:33:55 +02:00

637 lines
14 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"
"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(hlsURL)
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 {
s := strings.TrimSpace(input)
if s == "" {
return ""
}
// Falls volle URL
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])
}
}
// Fallback: rohe Eingabe
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"]
// 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"
}