581 lines
12 KiB
Go
581 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, 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 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", "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",
|
|
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 {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg)
|
|
}
|
|
return appErrorf("ffmpeg m3u8 failed: %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
|
|
}
|