nsfwapp/backend/record_stream_hls.go
2026-05-24 21:13:11 +02:00

685 lines
14 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, appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode)
}
rawBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, appErrorf("m3u8 read: %w", err)
}
masterText := string(rawBody)
playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true)
if err != nil {
return nil, appErrorf("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 appErrorf("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 isExitStatus(err error, code int) bool {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode() == code
}
return false
}
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 appErrorf("ungültige video URL: %q", videoURL)
}
if audioURL != "" {
au, err := url.Parse(audioURL)
if err != nil || (au.Scheme != "http" && au.Scheme != "https") {
return appErrorf("ungültige audio URL: %q", audioURL)
}
}
outFile = strings.TrimSpace(outFile)
if outFile == "" {
return errors.New("output file path leer")
}
ext := strings.ToLower(filepath.Ext(outFile))
if ext != ".ts" && ext != ".mp4" {
return appErrorf("nicht unterstützte output-endung: %q", ext)
}
// Wichtig:
// handleM3U8Mode schreibt immer in den übergebenen outFile.
// Wenn der Caller einen Retry-Chunk möchte, muss er bereits
// einen Retry-Pfad als outFile übergeben.
args := []string{
"-y",
"-hide_banner",
"-nostats",
"-loglevel", "error",
"-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 {
args = append(args,
"-rw_timeout", "15000000",
// HLS-Segmente robuster laden:
// kurze Netzwerkhänger / einzelne kaputte Segment-Requests nicht sofort hart abbrechen.
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
"-reconnect_delay_max", "4",
)
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",
// Hilft, wenn Retry-Chunks später byteweise an die Haupt-TS angehängt werden.
// PAT/PMT Header werden öfter erneut geschrieben.
"-mpegts_flags", "+resend_headers",
outFile,
)
case ".mp4":
args = append(args,
"-movflags", "+faststart",
outFile,
)
}
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:
sz := safeFileSize(outFile)
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 {
// Wenn der User den Recording-Prozess stoppt, beendet ffmpeg HLS unter Windows
// oft mit "exit status 1". Das ist in diesem Fall kein echter Fehler.
if ctx.Err() != nil && isExitStatus(err, 1) {
return nil
}
return hlsFFmpegFailedError(job, referer, err, stderr.String())
}
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
}
func hlsErrorModelName(job *RecordJob, referer string) string {
if job != nil {
// 1) Beste Quelle bei dir: Output-Dateiname
// Beispiel: aurora_natsuki_05_08_2026__12-34-56.mp4 -> aurora_natsuki
if out := strings.TrimSpace(job.Output); out != "" {
stem := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out))
stem = stripHotPrefix(stem)
if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" {
return s
}
if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" {
return s
}
}
// 2) Fallback: SourceURL
if s := modelNameFromURL(strings.TrimSpace(job.SourceURL)); s != "" {
return s
}
// 3) Fallback: Job-ID
if s := strings.TrimSpace(job.ID); s != "" {
return s
}
}
// 4) Fallback: Referer
if s := modelNameFromURL(strings.TrimSpace(referer)); s != "" {
return s
}
return ""
}
func modelNameFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if frag := strings.Trim(strings.TrimSpace(u.Fragment), "/"); frag != "" {
parts := strings.Split(frag, "/")
return strings.TrimSpace(parts[len(parts)-1])
}
path := strings.Trim(u.Path, "/")
if path == "" {
return ""
}
parts := strings.Split(path, "/")
return strings.TrimSpace(parts[len(parts)-1])
}
func hlsFFmpegFailedError(job *RecordJob, referer string, err error, stderr string) error {
model := hlsErrorModelName(job, referer)
msg := strings.TrimSpace(stderr)
prefix := "ffmpeg m3u8 failed"
if model != "" {
prefix = "[" + model + "] " + prefix
}
if msg != "" {
return appErrorf("%s: %w: %s", prefix, err, msg)
}
return appErrorf("%s: %w", prefix, err)
}