nsfwapp/backend/record_stream_hls.go
2026-04-04 13:27:52 +02:00

275 lines
5.7 KiB
Go

// backend\record_stream_hls.go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/grafov/m3u8"
)
func getWantedResolutionPlaylistWithHeaders(
ctx context.Context,
playlistURL string,
httpCookie string,
userAgent string,
referer string,
) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil)
if err != nil {
return "", err
}
if strings.TrimSpace(userAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
}
if strings.TrimSpace(httpCookie) != "" {
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
}
if strings.TrimSpace(referer) != "" {
ref := strings.TrimSpace(referer)
req.Header.Set("Referer", ref)
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
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 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(parts[1]); err == nil {
h = hh
}
}
}
fr := 30.0
if v.FrameRate > 0 {
fr = v.FrameRate
}
if h > bestHeight || (h == bestHeight && fr > bestFramerate) {
bestHeight = h
bestFramerate = fr
bestURI = strings.TrimSpace(v.URI)
}
}
if bestURI == "" {
return "", errors.New("Master-Playlist ohne gültige Varianten")
}
baseURL, err := url.Parse(playlistURL)
if err != nil {
return "", err
}
refURL, err := url.Parse(bestURI)
if err != nil {
return "", err
}
return baseURL.ResolveReference(refURL).String(), nil
}
func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string {
lines := make([]string, 0, 6)
if strings.TrimSpace(httpCookie) != "" {
lines = append(lines, fmt.Sprintf("Cookie: %s", strings.TrimSpace(httpCookie)))
}
if strings.TrimSpace(referer) != "" {
ref := strings.TrimSpace(referer)
lines = append(lines, fmt.Sprintf("Referer: %s", ref))
if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
lines = append(lines, fmt.Sprintf("Origin: %s://%s", ru.Scheme, ru.Host))
}
}
if strings.TrimSpace(userAgent) != "" {
lines = append(lines, fmt.Sprintf("User-Agent: %s", strings.TrimSpace(userAgent)))
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\r\n") + "\r\n"
}
func handleM3U8Mode(
ctx context.Context,
m3u8URL string,
outFile string,
job *RecordJob,
httpCookie string,
userAgent string,
referer string,
) error {
u, err := url.Parse(m3u8URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige URL: %q", m3u8URL)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m3u8URL, nil)
if err != nil {
return err
}
if strings.TrimSpace(userAgent) != "" {
req.Header.Set("User-Agent", strings.TrimSpace(userAgent))
}
if strings.TrimSpace(httpCookie) != "" {
req.Header.Set("Cookie", strings.TrimSpace(httpCookie))
}
if strings.TrimSpace(referer) != "" {
req.Header.Set("Referer", strings.TrimSpace(referer))
if ru, err := url.Parse(strings.TrimSpace(referer)); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" {
req.Header.Set("Origin", ru.Scheme+"://"+ru.Host)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
if strings.TrimSpace(outFile) == "" {
return errors.New("output file path leer")
}
args := []string{
"-y",
"-hide_banner",
"-nostats",
"-loglevel", "warning",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
"-http_persistent", "false",
}
ua := strings.TrimSpace(userAgent)
ref := strings.TrimSpace(referer)
ck := strings.TrimSpace(httpCookie)
if ua != "" {
args = append(args, "-user_agent", ua)
}
if ref != "" {
args = append(args, "-referer", ref)
}
if ck != "" {
args = append(args, "-cookies", ck)
}
if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" {
args = append(args, "-headers", hdr)
}
args = append(args,
"-i", m3u8URL,
"-c", "copy",
"-movflags", "+faststart",
outFile,
)
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
var stderr bytes.Buffer
cmd.Stdout = io.Discard
cmd.Stderr = &stderr
stopStat := make(chan struct{})
if job != nil {
go func() {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
var last int64
for {
select {
case <-ctx.Done():
return
case <-stopStat:
return
case <-t.C:
fi, err := os.Stat(outFile)
if err != nil || fi == nil || fi.IsDir() {
continue
}
sz := fi.Size()
if sz > 0 && sz != last {
jobsMu.Lock()
job.SizeBytes = sz
jobsMu.Unlock()
last = sz
_ = publishJob(job.ID)
}
}
}
}()
}
err = cmd.Run()
close(stopStat)
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg)
}
return fmt.Errorf("ffmpeg m3u8 failed: %w", err)
}
return nil
}