nsfwapp/backend/record_stream_hls.go
2026-05-02 21:19:15 +02:00

614 lines
12 KiB
Go

// backend\record_stream_hls.go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"github.com/grafov/m3u8"
)
type selectedHLSStream struct {
MasterURL string
VideoURL string
AudioURL string
HasSeparateAudio bool
Height int
FPS float64
}
type hlsAltMedia struct {
Type string
GroupID string
Name string
URI string
}
func parseHLSAttributeList(s string) map[string]string {
out := map[string]string{}
s = strings.TrimSpace(s)
for len(s) > 0 {
eq := strings.IndexByte(s, '=')
if eq <= 0 {
break
}
key := strings.TrimSpace(s[:eq])
rest := s[eq+1:]
var val string
if strings.HasPrefix(rest, `"`) {
rest = rest[1:]
end := strings.IndexByte(rest, '"')
if end == -1 {
val = rest
s = ""
} else {
val = rest[:end]
rest = rest[end+1:]
if strings.HasPrefix(rest, ",") {
rest = rest[1:]
}
s = rest
}
} else {
comma := strings.IndexByte(rest, ',')
if comma == -1 {
val = rest
s = ""
} else {
val = rest[:comma]
s = rest[comma+1:]
}
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
if key != "" {
out[key] = val
}
}
return out
}
func parseAltAudioFromMaster(masterText string) []hlsAltMedia {
lines := strings.Split(masterText, "\n")
out := make([]hlsAltMedia, 0, 8)
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "#EXT-X-MEDIA:") {
continue
}
attrText := strings.TrimPrefix(line, "#EXT-X-MEDIA:")
attrs := parseHLSAttributeList(attrText)
if !strings.EqualFold(strings.TrimSpace(attrs["TYPE"]), "AUDIO") {
continue
}
out = append(out, hlsAltMedia{
Type: strings.TrimSpace(attrs["TYPE"]),
GroupID: strings.TrimSpace(attrs["GROUP-ID"]),
Name: strings.TrimSpace(attrs["NAME"]),
URI: strings.TrimSpace(attrs["URI"]),
})
}
return out
}
func getWantedResolutionPlaylistWithHeaders(
ctx context.Context,
playlistURL string,
httpCookie string,
userAgent string,
referer string,
) (*selectedHLSStream, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil)
if err != nil {
return nil, 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 nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("m3u8 read: %w", err)
}
masterText := string(rawBody)
playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true)
if err != nil {
return nil, fmt.Errorf("m3u8 parse: %w", err)
}
// Schon Media-Playlist
if listType == m3u8.MEDIA {
return &selectedHLSStream{
MasterURL: playlistURL,
VideoURL: playlistURL,
AudioURL: "",
HasSeparateAudio: false,
Height: 0,
FPS: 0,
}, nil
}
master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil {
return nil, errors.New("unerwarteter playlist-typ: keine master-playlist")
}
altAudio := parseAltAudioFromMaster(masterText)
baseURL, err := url.Parse(playlistURL)
if err != nil {
return nil, err
}
var bestURI string
var bestHeight int
var bestFramerate float64
var bestAudioGroup string
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
}
audioGroup := strings.TrimSpace(v.Audio)
shouldTake := false
switch {
case bestURI == "":
shouldTake = true
case h > bestHeight:
shouldTake = true
case h == bestHeight && fr > bestFramerate:
shouldTake = true
}
if shouldTake {
bestURI = strings.TrimSpace(v.URI)
bestHeight = h
bestFramerate = fr
bestAudioGroup = audioGroup
}
}
if bestURI == "" {
return nil, errors.New("Master-Playlist ohne gültige Varianten")
}
refURL, err := url.Parse(bestURI)
if err != nil {
return nil, err
}
videoURL := baseURL.ResolveReference(refURL).String()
audioURL := ""
hasSeparateAudio := false
if bestAudioGroup != "" {
hasSeparateAudio = true
var bestAudioURI string
var bestAudioName string
for _, a := range altAudio {
if strings.TrimSpace(a.GroupID) != bestAudioGroup {
continue
}
if strings.TrimSpace(a.URI) == "" {
continue
}
name := strings.ToLower(strings.TrimSpace(a.Name))
uri := strings.TrimSpace(a.URI)
if bestAudioURI == "" {
bestAudioURI = uri
bestAudioName = name
continue
}
score := func(s string) int {
n := strings.ToLower(strings.TrimSpace(s))
points := 0
if strings.Contains(n, "128") {
points += 100
}
if strings.Contains(n, "high") {
points += 20
}
if strings.Contains(n, "aac") {
points += 10
}
return points
}
if score(name) > score(bestAudioName) {
bestAudioURI = uri
bestAudioName = name
}
}
if bestAudioURI == "" {
hasSeparateAudio = false
} else {
audioRef, err := url.Parse(bestAudioURI)
if err != nil {
return nil, err
}
audioURL = baseURL.ResolveReference(audioRef).String()
}
}
return &selectedHLSStream{
MasterURL: playlistURL,
VideoURL: videoURL,
AudioURL: audioURL,
HasSeparateAudio: hasSeparateAudio,
Height: bestHeight,
FPS: bestFramerate,
}, 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 hlsRetryChunkPath(outFile string, attempt int) string {
dir := filepath.Dir(outFile)
ext := filepath.Ext(outFile)
base := strings.TrimSuffix(filepath.Base(outFile), ext)
canonical := canonicalAssetIDFromName(base)
if canonical == "" {
canonical = base
}
ts := time.Now().Format("20060102_150405_000")
return filepath.Join(dir, fmt.Sprintf(".%s.retry.%02d.%s%s", canonical, attempt, ts, ext))
}
func appendFileAndRemove(dstPath, srcPath string) error {
srcPath = strings.TrimSpace(srcPath)
dstPath = strings.TrimSpace(dstPath)
if srcPath == "" || dstPath == "" {
return fmt.Errorf("append path leer")
}
if filepath.Clean(srcPath) == filepath.Clean(dstPath) {
return nil
}
sfi, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if sfi == nil || sfi.IsDir() || sfi.Size() <= 0 {
_ = removeWithRetry(srcPath)
return nil
}
src, err := os.Open(srcPath)
if err != nil {
return err
}
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
_ = src.Close()
return err
}
_, copyErr := io.Copy(dst, src)
closeSrcErr := src.Close()
closeDstErr := dst.Close()
if copyErr != nil {
return copyErr
}
if closeSrcErr != nil {
return closeSrcErr
}
if closeDstErr != nil {
return closeDstErr
}
if err := removeWithRetry(srcPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func cleanupHLSRetryChunks(outFile string) {
dir := filepath.Dir(outFile)
ext := filepath.Ext(outFile)
base := strings.TrimSuffix(filepath.Base(outFile), ext)
canonical := canonicalAssetIDFromName(base)
if canonical == "" {
canonical = base
}
pattern := filepath.Join(dir, fmt.Sprintf(".%s.retry.*%s", canonical, ext))
matches, err := filepath.Glob(pattern)
if err != nil {
return
}
for _, p := range matches {
_ = removeWithRetry(p)
}
}
func handleM3U8Mode(
ctx context.Context,
stream *selectedHLSStream,
outFile string,
job *RecordJob,
httpCookie string,
userAgent string,
referer string,
) error {
if stream == nil {
return errors.New("stream config nil")
}
videoURL := strings.TrimSpace(stream.VideoURL)
audioURL := strings.TrimSpace(stream.AudioURL)
u, err := url.Parse(videoURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("ungültige video URL: %q", videoURL)
}
if audioURL != "" {
au, err := url.Parse(audioURL)
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
return fmt.Errorf("ungültige audio URL: %q", audioURL)
}
}
outFile = strings.TrimSpace(outFile)
if outFile == "" {
return errors.New("output file path leer")
}
cleanupHLSRetryChunks(outFile)
defer cleanupHLSRetryChunks(outFile)
ext := strings.ToLower(filepath.Ext(outFile))
if ext != ".ts" && ext != ".mp4" {
return fmt.Errorf("nicht unterstützte output-endung: %q", ext)
}
// Wichtig: ffmpeg schreibt in temporäre Retry-Datei.
// Danach wird sie an outFile angehängt und gelöscht.
retryOut := hlsRetryChunkPath(outFile, 1)
defer func() {
_ = removeWithRetry(retryOut)
}()
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)
appendInputOptions := func(args []string) []string {
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)
}
return args
}
if audioURL != "" {
args = appendInputOptions(args)
args = append(args, "-i", videoURL)
args = appendInputOptions(args)
args = append(args, "-i", audioURL)
args = append(args,
"-map", "0:v:0",
"-map", "1:a:0?",
"-dn",
"-ignore_unknown",
"-c", "copy",
)
} else {
args = appendInputOptions(args)
args = append(args,
"-i", videoURL,
"-map", "0:v:0",
"-map", "0:a:0?",
"-dn",
"-ignore_unknown",
"-c", "copy",
)
}
switch ext {
case ".ts":
args = append(args,
"-f", "mpegts",
retryOut,
)
case ".mp4":
args = append(args,
"-movflags", "+faststart",
retryOut,
)
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
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:
mainSize := safeFileSize(outFile)
retrySize := safeFileSize(retryOut)
sz := mainSize + retrySize
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)
}
if err := appendFileAndRemove(outFile, retryOut); err != nil {
return fmt.Errorf("retry chunk übernehmen fehlgeschlagen: %w", err)
}
if job != nil {
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
_ = publishJob(job.ID)
}
}
return nil
}