1714 lines
37 KiB
Go
1714 lines
37 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: %s", resp.StatusCode, playlistURL)
|
|
}
|
|
|
|
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 hlsAttrValue(line string, key string) string {
|
|
line = strings.TrimSpace(line)
|
|
key = strings.TrimSpace(key)
|
|
if line == "" || key == "" {
|
|
return ""
|
|
}
|
|
|
|
needle := key + "="
|
|
idx := strings.Index(line, needle)
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
|
|
rest := strings.TrimSpace(line[idx+len(needle):])
|
|
if rest == "" {
|
|
return ""
|
|
}
|
|
|
|
if strings.HasPrefix(rest, `"`) {
|
|
rest = rest[1:]
|
|
end := strings.IndexByte(rest, '"')
|
|
if end < 0 {
|
|
return strings.TrimSpace(rest)
|
|
}
|
|
return strings.TrimSpace(rest[:end])
|
|
}
|
|
|
|
if comma := strings.IndexByte(rest, ','); comma >= 0 {
|
|
rest = rest[:comma]
|
|
}
|
|
|
|
return strings.Trim(strings.TrimSpace(rest), `"`)
|
|
}
|
|
|
|
func hlsExtXMapURI(playlistText string) string {
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "#EXT-X-MAP:") {
|
|
return hlsAttrValue(line, "URI")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hlsPartURIsFromText(playlistText string) []string {
|
|
out := make([]string, 0, 16)
|
|
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "#EXT-X-PART:") {
|
|
continue
|
|
}
|
|
|
|
uri := hlsAttrValue(line, "URI")
|
|
if uri != "" {
|
|
out = append(out, uri)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func hlsMediaSegmentURIsFromText(playlistText string) []string {
|
|
out := make([]string, 0, 16)
|
|
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
out = append(out, line)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func hlsRefreshDelay(playlistText string) time.Duration {
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
|
|
if strings.HasPrefix(line, "#EXT-X-PART-INF:") {
|
|
raw := hlsAttrValue(line, "PART-TARGET")
|
|
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
|
d := time.Duration(f * float64(time.Second))
|
|
if d < 500*time.Millisecond {
|
|
d = 500 * time.Millisecond
|
|
}
|
|
if d > 2*time.Second {
|
|
d = 2 * time.Second
|
|
}
|
|
return d
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") {
|
|
raw := strings.TrimSpace(strings.TrimPrefix(line, "#EXT-X-TARGETDURATION:"))
|
|
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
|
|
d := time.Duration(f * float64(time.Second))
|
|
if d < 1*time.Second {
|
|
d = 1 * time.Second
|
|
}
|
|
if d > 3*time.Second {
|
|
d = 3 * time.Second
|
|
}
|
|
return d
|
|
}
|
|
}
|
|
}
|
|
|
|
return 1 * time.Second
|
|
}
|
|
|
|
func hlsResolveURI(baseRaw string, refRaw string) (string, error) {
|
|
baseRaw = strings.TrimSpace(baseRaw)
|
|
refRaw = strings.TrimSpace(refRaw)
|
|
|
|
if refRaw == "" {
|
|
return "", appErrorf("empty hls uri")
|
|
}
|
|
|
|
ref, err := url.Parse(refRaw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ref.IsAbs() {
|
|
return ref.String(), nil
|
|
}
|
|
|
|
base, err := url.Parse(baseRaw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return base.ResolveReference(ref).String(), nil
|
|
}
|
|
|
|
func hlsWithoutBlockingReloadParams(rawURL string) string {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return ""
|
|
}
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return rawURL
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Del("_HLS_msn")
|
|
q.Del("_HLS_part")
|
|
u.RawQuery = q.Encode()
|
|
|
|
return u.String()
|
|
}
|
|
|
|
func hlsWithBlockingReloadParams(rawURL string, msn int, part int) string {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" || msn < 0 {
|
|
return rawURL
|
|
}
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return rawURL
|
|
}
|
|
|
|
q := u.Query()
|
|
q.Set("_HLS_msn", strconv.Itoa(msn))
|
|
|
|
if part >= 0 {
|
|
q.Set("_HLS_part", strconv.Itoa(part))
|
|
} else {
|
|
q.Del("_HLS_part")
|
|
}
|
|
|
|
u.RawQuery = q.Encode()
|
|
|
|
return u.String()
|
|
}
|
|
|
|
func hlsURLPathKey(rawURL string) string {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return ""
|
|
}
|
|
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || u == nil {
|
|
return rawURL
|
|
}
|
|
|
|
return strings.TrimSpace(u.Path)
|
|
}
|
|
|
|
func hlsSamePlaylistPath(a string, b string) bool {
|
|
ak := hlsURLPathKey(a)
|
|
bk := hlsURLPathKey(b)
|
|
|
|
return ak != "" && bk != "" && ak == bk
|
|
}
|
|
|
|
func hlsParseChaturbateMediaSeqPart(uri string) (seq int, part int, isPart bool, ok bool) {
|
|
uri = strings.TrimSpace(uri)
|
|
if uri == "" {
|
|
return 0, 0, false, false
|
|
}
|
|
|
|
if before, _, found := strings.Cut(uri, "?"); found {
|
|
uri = before
|
|
}
|
|
|
|
if idx := strings.LastIndex(uri, "/"); idx >= 0 {
|
|
uri = uri[idx+1:]
|
|
}
|
|
|
|
pieces := strings.Split(uri, "_")
|
|
for i, p := range pieces {
|
|
switch p {
|
|
case "seg":
|
|
// seg_6_4240_audio_...
|
|
if i+2 >= len(pieces) {
|
|
continue
|
|
}
|
|
|
|
n, err := strconv.Atoi(strings.TrimSpace(pieces[i+2]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
return n, -1, false, true
|
|
|
|
case "part":
|
|
// part_6_4241_0_audio_...
|
|
if i+3 >= len(pieces) {
|
|
continue
|
|
}
|
|
|
|
n, err1 := strconv.Atoi(strings.TrimSpace(pieces[i+2]))
|
|
pn, err2 := strconv.Atoi(strings.TrimSpace(pieces[i+3]))
|
|
if err1 != nil || err2 != nil {
|
|
continue
|
|
}
|
|
|
|
return n, pn, true, true
|
|
}
|
|
}
|
|
|
|
return 0, 0, false, false
|
|
}
|
|
|
|
func hlsNextBlockingReloadParams(playlistText string) (msn int, part int, ok bool) {
|
|
maxSeg := -1
|
|
maxPartSeq := -1
|
|
maxPart := -1
|
|
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "#EXT-X-PART:") {
|
|
uri := hlsAttrValue(line, "URI")
|
|
if seq, pn, isPart, ok := hlsParseChaturbateMediaSeqPart(uri); ok && isPart {
|
|
if seq > maxPartSeq || (seq == maxPartSeq && pn > maxPart) {
|
|
maxPartSeq = seq
|
|
maxPart = pn
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
|
|
if seq, _, isPart, ok := hlsParseChaturbateMediaSeqPart(line); ok && !isPart {
|
|
if seq > maxSeg {
|
|
maxSeg = seq
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wenn Parts sichtbar sind, beim nächsten Request nach dem nächsten Part fragen.
|
|
if maxPartSeq >= 0 && maxPart >= 0 {
|
|
return maxPartSeq, maxPart + 1, true
|
|
}
|
|
|
|
// Sonst beim nächsten Media Sequence warten.
|
|
if maxSeg >= 0 {
|
|
return maxSeg + 1, 0, true
|
|
}
|
|
|
|
return -1, -1, false
|
|
}
|
|
|
|
func hlsRenditionReportPosition(
|
|
playlistText string,
|
|
basePlaylistURL string,
|
|
targetPlaylistURL string,
|
|
) (msn int, part int, ok bool) {
|
|
targetPlaylistURL = hlsWithoutBlockingReloadParams(targetPlaylistURL)
|
|
|
|
for _, line := range strings.Split(playlistText, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "#EXT-X-RENDITION-REPORT:") {
|
|
continue
|
|
}
|
|
|
|
attrText := strings.TrimPrefix(line, "#EXT-X-RENDITION-REPORT:")
|
|
attrs := parseHLSAttributeList(attrText)
|
|
|
|
reportURI := strings.TrimSpace(attrs["URI"])
|
|
if reportURI == "" {
|
|
continue
|
|
}
|
|
|
|
reportURL, err := hlsResolveURI(basePlaylistURL, reportURI)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
reportURL = hlsWithoutBlockingReloadParams(reportURL)
|
|
|
|
if !hlsSamePlaylistPath(reportURL, targetPlaylistURL) {
|
|
continue
|
|
}
|
|
|
|
rawMSN := strings.TrimSpace(attrs["LAST-MSN"])
|
|
if rawMSN == "" {
|
|
continue
|
|
}
|
|
|
|
n, err := strconv.Atoi(rawMSN)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
pn := 0
|
|
if rawPart := strings.TrimSpace(attrs["LAST-PART"]); rawPart != "" {
|
|
if v, err := strconv.Atoi(rawPart); err == nil {
|
|
pn = v
|
|
}
|
|
}
|
|
|
|
return n, pn, true
|
|
}
|
|
|
|
return -1, -1, false
|
|
}
|
|
|
|
func hlsPrimeRenditionURLFromPlaylist(
|
|
ctx context.Context,
|
|
sourcePlaylistURL string,
|
|
targetPlaylistURL string,
|
|
httpCookie string,
|
|
userAgent string,
|
|
referer string,
|
|
logPrefix string,
|
|
kind string,
|
|
) string {
|
|
sourcePlaylistURL = strings.TrimSpace(sourcePlaylistURL)
|
|
targetPlaylistURL = strings.TrimSpace(targetPlaylistURL)
|
|
|
|
if sourcePlaylistURL == "" || targetPlaylistURL == "" {
|
|
return targetPlaylistURL
|
|
}
|
|
|
|
body, err := hlsGetBytesWithHeadersRetry(
|
|
ctx,
|
|
sourcePlaylistURL,
|
|
httpCookie,
|
|
userAgent,
|
|
referer,
|
|
3,
|
|
)
|
|
if err != nil {
|
|
if verboseLogs() {
|
|
appLogln("⚠️", logPrefix, "unable to prime", kind, "playlist:", err)
|
|
}
|
|
return targetPlaylistURL
|
|
}
|
|
|
|
msn, part, ok := hlsRenditionReportPosition(
|
|
string(body),
|
|
sourcePlaylistURL,
|
|
targetPlaylistURL,
|
|
)
|
|
if !ok {
|
|
return targetPlaylistURL
|
|
}
|
|
|
|
primed := hlsWithBlockingReloadParams(targetPlaylistURL, msn, part)
|
|
|
|
if verboseLogs() {
|
|
appLogln("🔁", logPrefix, "primed", kind, "playlist:", primed)
|
|
}
|
|
|
|
return primed
|
|
}
|
|
|
|
func hlsGetBytesWithHeaders(
|
|
ctx context.Context,
|
|
rawURL string,
|
|
httpCookie string,
|
|
userAgent string,
|
|
referer string,
|
|
) ([]byte, error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return nil, appErrorf("empty hls url")
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, 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: %s", resp.StatusCode, rawURL)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func hlsGetBytesWithHeadersRetry(
|
|
ctx context.Context,
|
|
rawURL string,
|
|
httpCookie string,
|
|
userAgent string,
|
|
referer string,
|
|
attempts int,
|
|
) ([]byte, error) {
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
|
|
var lastErr error
|
|
|
|
for i := 1; i <= attempts; i++ {
|
|
b, err := hlsGetBytesWithHeaders(ctx, rawURL, httpCookie, userAgent, referer)
|
|
if err == nil {
|
|
return b, nil
|
|
}
|
|
|
|
lastErr = err
|
|
|
|
if ctx.Err() != nil {
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
|
|
// 401/403/404/410 sind bei CB meistens kein kurzer Netzwerkhänger,
|
|
// sondern stale/ungültige Playlist-URL. Nicht 3x denselben URL hämmern,
|
|
// sondern direkt nach außen geben, damit RecordStream frische HLS-URLs holt.
|
|
if strings.Contains(msg, "http 401") ||
|
|
strings.Contains(msg, "http 403") ||
|
|
strings.Contains(msg, "http 404") ||
|
|
strings.Contains(msg, "http 410") {
|
|
return nil, err
|
|
}
|
|
|
|
if i < attempts {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(600 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, lastErr
|
|
}
|
|
|
|
func writeStreamBytesAndPublish(f *os.File, job *RecordJob, b []byte) error {
|
|
if len(b) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if _, err := f.Write(b); err != nil {
|
|
return err
|
|
}
|
|
|
|
if job != nil {
|
|
if fi, err := f.Stat(); err == nil && fi != nil && !fi.IsDir() {
|
|
jobsMu.Lock()
|
|
job.SizeBytes = fi.Size()
|
|
jobsMu.Unlock()
|
|
|
|
_ = publishJobSnapshot(job.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func streamLogPrefix(job *RecordJob, referer string, outFile string) string {
|
|
if s := strings.TrimSpace(hlsErrorModelName(job, referer)); s != "" {
|
|
return "[" + s + "]"
|
|
}
|
|
|
|
outFile = strings.TrimSpace(outFile)
|
|
if outFile != "" {
|
|
stem := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile))
|
|
stem = stripHotPrefix(stem)
|
|
|
|
if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" {
|
|
return "[" + s + "]"
|
|
}
|
|
if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" {
|
|
return "[" + s + "]"
|
|
}
|
|
}
|
|
|
|
return "[stream]"
|
|
}
|
|
|
|
func streamSidecarPath(outFile string, suffix string, ext string) string {
|
|
outFile = strings.TrimSpace(outFile)
|
|
suffix = strings.Trim(strings.TrimSpace(suffix), ".")
|
|
ext = strings.TrimSpace(ext)
|
|
|
|
if outFile == "" || suffix == "" {
|
|
return ""
|
|
}
|
|
if ext == "" {
|
|
ext = filepath.Ext(outFile)
|
|
}
|
|
if !strings.HasPrefix(ext, ".") {
|
|
ext = "." + ext
|
|
}
|
|
|
|
dir := filepath.Dir(outFile)
|
|
base := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile))
|
|
|
|
canonical := canonicalAssetIDFromName(base)
|
|
if canonical == "" {
|
|
canonical = base
|
|
}
|
|
|
|
return filepath.Join(dir, "."+canonical+"."+suffix+ext)
|
|
}
|
|
|
|
func muxAudioVideoSegmentFiles(
|
|
ctx context.Context,
|
|
videoPath string,
|
|
audioPath string,
|
|
outFile string,
|
|
logPrefix string,
|
|
) error {
|
|
videoPath = strings.TrimSpace(videoPath)
|
|
audioPath = strings.TrimSpace(audioPath)
|
|
outFile = strings.TrimSpace(outFile)
|
|
|
|
if videoPath == "" || audioPath == "" || outFile == "" {
|
|
return appErrorf("%s mux paths empty", logPrefix)
|
|
}
|
|
if !fileExistsNonEmpty(videoPath) {
|
|
return appErrorf("%s mux video missing or empty", logPrefix)
|
|
}
|
|
if !fileExistsNonEmpty(audioPath) {
|
|
return appErrorf("%s mux audio missing or empty", logPrefix)
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(outFile))
|
|
tmp := streamSidecarPath(outFile, "muxing", ext)
|
|
backup := streamSidecarPath(outFile, "video-original", ".bak")
|
|
|
|
_ = removeWithRetry(tmp)
|
|
_ = removeWithRetry(backup)
|
|
|
|
args := []string{
|
|
"-y",
|
|
"-hide_banner",
|
|
"-loglevel", "error",
|
|
|
|
"-fflags", "+genpts+discardcorrupt",
|
|
"-err_detect", "ignore_err",
|
|
|
|
"-i", videoPath,
|
|
"-i", audioPath,
|
|
|
|
"-map", "0:v:0",
|
|
"-map", "1:a:0",
|
|
"-dn",
|
|
"-ignore_unknown",
|
|
"-c", "copy",
|
|
}
|
|
|
|
switch ext {
|
|
case ".ts":
|
|
args = append(args, "-f", "mpegts", tmp)
|
|
case ".mp4":
|
|
args = append(args, "-movflags", "+faststart", tmp)
|
|
default:
|
|
args = append(args, "-f", "mpegts", tmp)
|
|
}
|
|
|
|
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
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
_ = removeWithRetry(tmp)
|
|
return appErrorf("%s mux failed: %w (%s)", logPrefix, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
if err := requireNonEmptyRegularFile(tmp, "mux result"); err != nil {
|
|
_ = removeWithRetry(tmp)
|
|
return err
|
|
}
|
|
|
|
sameOutputAsVideo := filepath.Clean(videoPath) == filepath.Clean(outFile)
|
|
|
|
if sameOutputAsVideo {
|
|
// Recording-Fall:
|
|
// outFile ist die laufende Video-Datei selbst, also sicher ersetzen.
|
|
if err := os.Rename(outFile, backup); err != nil {
|
|
_ = removeWithRetry(tmp)
|
|
return appErrorf("%s mux backup rename failed: %w", logPrefix, err)
|
|
}
|
|
|
|
if err := os.Rename(tmp, outFile); err != nil {
|
|
_ = os.Rename(backup, outFile)
|
|
_ = removeWithRetry(tmp)
|
|
return appErrorf("%s mux final rename failed: %w", logPrefix, err)
|
|
}
|
|
|
|
_ = removeWithRetry(backup)
|
|
} else {
|
|
// Postwork-Fall:
|
|
// videoPath ist z.B. records/foo.ts, outFile ist eine neue temp/foo.mp4.
|
|
// Hier darf outFile noch nicht existieren.
|
|
_ = removeWithRetry(outFile)
|
|
|
|
if err := os.Rename(tmp, outFile); err != nil {
|
|
_ = removeWithRetry(tmp)
|
|
return appErrorf("%s mux final rename failed: %w", logPrefix, err)
|
|
}
|
|
}
|
|
|
|
_ = removeWithRetry(audioPath)
|
|
|
|
return nil
|
|
}
|
|
|
|
func recordHLSPlaylistSegments(
|
|
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")
|
|
}
|
|
|
|
logPrefix := streamLogPrefix(job, referer, outFile)
|
|
|
|
videoURL := strings.TrimSpace(stream.VideoURL)
|
|
audioURL := strings.TrimSpace(stream.AudioURL)
|
|
|
|
if videoURL == "" {
|
|
return appErrorf("%s video media playlist url empty", logPrefix)
|
|
}
|
|
|
|
// Chaturbate LL-HLS:
|
|
// Die Website lädt Audio/Video-Playlists oft mit _HLS_msn/_HLS_part.
|
|
// Die rohen chunklist_...m3u8 URLs ohne diese Parameter liefern bei dir häufig 403.
|
|
// Deshalb primen wir die Audio-URL aus den RENDITION-REPORTs der Video-Playlist.
|
|
if audioURL != "" {
|
|
audioURL = hlsPrimeRenditionURLFromPlaylist(
|
|
ctx,
|
|
videoURL,
|
|
audioURL,
|
|
httpCookie,
|
|
userAgent,
|
|
referer,
|
|
logPrefix,
|
|
"audio",
|
|
)
|
|
}
|
|
|
|
// Kein separates Audio vorhanden: bisheriges Verhalten.
|
|
if audioURL == "" {
|
|
return recordHLSMediaPlaylistToFile(
|
|
ctx,
|
|
videoURL,
|
|
outFile,
|
|
job,
|
|
httpCookie,
|
|
userAgent,
|
|
referer,
|
|
logPrefix,
|
|
"video",
|
|
)
|
|
}
|
|
|
|
// Separates Audio vorhanden: Video in Hauptdatei, Audio in Sidecar-Datei.
|
|
audioOut := streamSidecarPath(outFile, "audio", ".m4s")
|
|
if audioOut == "" {
|
|
return appErrorf("%s audio sidecar path empty", logPrefix)
|
|
}
|
|
|
|
if verboseLogs() {
|
|
appLogln("🎥", logPrefix, "recording separate video/audio playlists")
|
|
appLogln("🎥", logPrefix, "video playlist:", videoURL)
|
|
appLogln("🎧", logPrefix, "audio playlist:", audioURL)
|
|
}
|
|
|
|
recCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
errCh := make(chan error, 2)
|
|
|
|
go func() {
|
|
errCh <- recordHLSMediaPlaylistToFile(
|
|
recCtx,
|
|
videoURL,
|
|
outFile,
|
|
job,
|
|
httpCookie,
|
|
userAgent,
|
|
referer,
|
|
logPrefix,
|
|
"video",
|
|
)
|
|
}()
|
|
|
|
go func() {
|
|
errCh <- recordHLSMediaPlaylistToFile(
|
|
recCtx,
|
|
audioURL,
|
|
audioOut,
|
|
nil,
|
|
httpCookie,
|
|
userAgent,
|
|
referer,
|
|
logPrefix,
|
|
"audio",
|
|
)
|
|
}()
|
|
|
|
firstErr := <-errCh
|
|
cancel()
|
|
secondErr := <-errCh
|
|
|
|
parentCanceled := ctx.Err() != nil
|
|
endOfStream := errors.Is(firstErr, io.EOF) || errors.Is(secondErr, io.EOF)
|
|
|
|
// Wenn einer der beiden Streams wegen Fehler endet, canceln wir den anderen.
|
|
// Dessen "context canceled" ist dann nur Folgefehler und soll nicht das Log aufblasen.
|
|
finalErr := firstErr
|
|
if errors.Is(finalErr, context.Canceled) && !parentCanceled {
|
|
finalErr = secondErr
|
|
}
|
|
if errors.Is(secondErr, context.Canceled) && !parentCanceled {
|
|
secondErr = nil
|
|
}
|
|
|
|
// Falls firstErr nil/context-canceled ist, aber secondErr der echte Fehler war.
|
|
if finalErr == nil || (errors.Is(finalErr, context.Canceled) && !parentCanceled) {
|
|
finalErr = secondErr
|
|
}
|
|
|
|
shouldFinalize := parentCanceled || endOfStream || finalErr == nil
|
|
|
|
if shouldFinalize {
|
|
if !fileExistsNonEmpty(outFile) {
|
|
if finalErr != nil {
|
|
return finalErr
|
|
}
|
|
return appErrorf("%s video output missing or empty", logPrefix)
|
|
}
|
|
|
|
if !fileExistsNonEmpty(audioOut) {
|
|
if finalErr != nil {
|
|
return appErrorf("%s audio missing/empty; refusing video-only file: %w", logPrefix, finalErr)
|
|
}
|
|
return appErrorf("%s audio missing/empty; refusing video-only file", logPrefix)
|
|
}
|
|
|
|
muxCtx, muxCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
muxErr := muxAudioVideoSegmentFiles(muxCtx, outFile, audioOut, outFile, logPrefix)
|
|
muxCancel()
|
|
|
|
if muxErr != nil {
|
|
return appErrorf("%s audio/video mux failed; refusing video-only file: %w", logPrefix, muxErr)
|
|
}
|
|
|
|
// Sicherheitscheck: Nach dem Mux muss wirklich Audio vorhanden sein.
|
|
if !videoHasAudioStream(context.Background(), outFile) {
|
|
return appErrorf("%s mux result has no audio stream; refusing video-only file", logPrefix)
|
|
}
|
|
|
|
if job != nil {
|
|
if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() {
|
|
jobsMu.Lock()
|
|
job.SizeBytes = fi.Size()
|
|
jobsMu.Unlock()
|
|
_ = publishJobSnapshot(job.ID)
|
|
}
|
|
}
|
|
|
|
if verboseLogs() {
|
|
appLogln("✅", logPrefix, "audio/video mux succeeded:", filepath.Base(outFile))
|
|
}
|
|
|
|
if parentCanceled {
|
|
return ctx.Err()
|
|
}
|
|
if endOfStream {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return finalErr
|
|
}
|
|
|
|
func recordHLSMediaPlaylistToFile(
|
|
ctx context.Context,
|
|
mediaURL string,
|
|
outFile string,
|
|
job *RecordJob,
|
|
httpCookie string,
|
|
userAgent string,
|
|
referer string,
|
|
logPrefix string,
|
|
kind string,
|
|
) error {
|
|
mediaURL = strings.TrimSpace(mediaURL)
|
|
outFile = strings.TrimSpace(outFile)
|
|
kind = strings.TrimSpace(kind)
|
|
|
|
if mediaURL == "" {
|
|
return appErrorf("%s %s playlist url empty", logPrefix, kind)
|
|
}
|
|
if outFile == "" {
|
|
return appErrorf("%s %s output path empty", logPrefix, kind)
|
|
}
|
|
|
|
baseMediaURL := hlsWithoutBlockingReloadParams(mediaURL)
|
|
requestURL := mediaURL
|
|
|
|
if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil {
|
|
return appErrorf("%s %s mkdir failed: %w", logPrefix, kind, err)
|
|
}
|
|
|
|
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
if err != nil {
|
|
return appErrorf("%s %s open output failed: %w", logPrefix, kind, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
seen := map[string]struct{}{}
|
|
writtenMaps := map[string]struct{}{}
|
|
|
|
lastNewSegmentAt := time.Now()
|
|
|
|
if verboseLogs() {
|
|
appLogln("🎥", logPrefix, "recording", kind, "media playlist:", mediaURL)
|
|
}
|
|
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
body, err := hlsGetBytesWithHeadersRetry(ctx, requestURL, httpCookie, userAgent, referer, 3)
|
|
if err != nil {
|
|
return appErrorf("%s %s get playlist: %w", logPrefix, kind, err)
|
|
}
|
|
|
|
playlistText := string(body)
|
|
|
|
if nextMSN, nextPart, ok := hlsNextBlockingReloadParams(playlistText); ok {
|
|
requestURL = hlsWithBlockingReloadParams(baseMediaURL, nextMSN, nextPart)
|
|
} else {
|
|
requestURL = baseMediaURL
|
|
}
|
|
|
|
if strings.Contains(playlistText, "#EXT-X-STREAM-INF") {
|
|
return appErrorf("%s %s expected media playlist, got master playlist", logPrefix, kind)
|
|
}
|
|
|
|
segmentURIs := hlsMediaSegmentURIsFromText(playlistText)
|
|
if len(segmentURIs) == 0 {
|
|
segmentURIs = hlsPartURIsFromText(playlistText)
|
|
}
|
|
|
|
wroteAny := false
|
|
|
|
if len(segmentURIs) > 0 {
|
|
mapURI := hlsExtXMapURI(playlistText)
|
|
if mapURI != "" {
|
|
mapURL, err := hlsResolveURI(mediaURL, mapURI)
|
|
if err != nil {
|
|
return appErrorf("%s %s resolve map uri: %w", logPrefix, kind, err)
|
|
}
|
|
|
|
if _, ok := writtenMaps[mapURL]; !ok {
|
|
initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3)
|
|
if err != nil {
|
|
appLogln("⚠️", logPrefix, kind, "init segment failed:", err)
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(hlsRefreshDelay(playlistText)):
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil {
|
|
return appErrorf("%s %s write init: %w", logPrefix, kind, err)
|
|
}
|
|
|
|
writtenMaps[mapURL] = struct{}{}
|
|
wroteAny = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, uri := range segmentURIs {
|
|
uri = strings.TrimSpace(uri)
|
|
if uri == "" {
|
|
continue
|
|
}
|
|
|
|
segmentURL, err := hlsResolveURI(mediaURL, uri)
|
|
if err != nil {
|
|
appLogln("⚠️", logPrefix, kind, "resolve segment failed:", uri, err)
|
|
continue
|
|
}
|
|
|
|
if _, ok := seen[segmentURL]; ok {
|
|
continue
|
|
}
|
|
|
|
segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3)
|
|
if err != nil {
|
|
appLogln("⚠️", logPrefix, kind, "segment failed:", filepath.Base(uri), err)
|
|
continue
|
|
}
|
|
|
|
if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil {
|
|
return appErrorf("%s %s write segment: %w", logPrefix, kind, err)
|
|
}
|
|
|
|
seen[segmentURL] = struct{}{}
|
|
wroteAny = true
|
|
}
|
|
|
|
if wroteAny {
|
|
lastNewSegmentAt = time.Now()
|
|
} else if time.Since(lastNewSegmentAt) > 75*time.Second {
|
|
return io.EOF
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(hlsRefreshDelay(playlistText)):
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com")
|
|
|
|
// Wichtig:
|
|
// Bei Chaturbate die MasterURL NICHT nochmal an ffmpeg geben.
|
|
// Die Master-Playlist wurde vorher schon in Go geöffnet/parst.
|
|
// Das erneute Öffnen der llhls.m3u8 führt bei dir häufig zu 403.
|
|
inputURL := videoURL
|
|
useSeparateAudioInput := false
|
|
|
|
if isChaturbate {
|
|
// Stabilitäts-Test:
|
|
// Chaturbate erstmal NICHT mit separatem Audio-Input öffnen.
|
|
// Wenn danach sofort .ts-Dateien entstehen, war der separate Audio-Input der Blocker.
|
|
useSeparateAudioInput = false
|
|
} else if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" {
|
|
inputURL = strings.TrimSpace(stream.MasterURL)
|
|
}
|
|
|
|
u, err := url.Parse(inputURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return appErrorf("ungültige input URL: %q", inputURL)
|
|
}
|
|
|
|
if useSeparateAudioInput {
|
|
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,
|
|
// 30s statt 15s: CB/CDN hat gelegentlich kurze Hänger.
|
|
"-rw_timeout", "30000000",
|
|
|
|
"-reconnect", "1",
|
|
"-reconnect_at_eof", "1",
|
|
"-reconnect_streamed", "1",
|
|
"-reconnect_on_network_error", "1",
|
|
"-reconnect_on_http_error", "403,404,408,429,500,502,503,504",
|
|
"-reconnect_delay_max", "10",
|
|
)
|
|
|
|
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 useSeparateAudioInput {
|
|
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", inputURL,
|
|
"-map", "0:v:0",
|
|
)
|
|
|
|
if isChaturbate && stream.HasSeparateAudio {
|
|
// Bei CB mit separater Audio-Playlist darf keine stumme Datei entstehen.
|
|
args = append(args, "-map", "0:a:0")
|
|
} else {
|
|
args = append(args, "-map", "0:a:0?")
|
|
}
|
|
|
|
args = append(args,
|
|
"-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
|
|
_ = publishJobSnapshot(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()
|
|
_ = publishJobSnapshot(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)
|
|
}
|