Files
djk/internal/app/playback_stream.go

354 lines
11 KiB
Go

package app
import (
"context"
"crypto/aes"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
var playbackDurationPattern = regexp.MustCompile(`Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)`)
var playbackLevelPattern = regexp.MustCompile(`profile Constrained Baseline, level (\d+)\.(\d+)`)
type playbackLog struct {
mu sync.Mutex
text cappedStringWriter
duration float64
mime string
ready chan struct{}
once sync.Once
}
func (log *playbackLog) Write(data []byte) (int, error) {
log.mu.Lock()
defer log.mu.Unlock()
_, _ = log.text.Write(data)
if log.mime == "" {
if match := playbackLevelPattern.FindStringSubmatch(log.text.String()); len(match) == 3 {
major, _ := strconv.Atoi(match[1])
minor, _ := strconv.Atoi(match[2])
log.mime = fmt.Sprintf(`video/mp4; codecs="avc1.42C0%02X, mp4a.40.2"`, major*10+minor)
}
}
if log.duration == 0 {
if match := playbackDurationPattern.FindStringSubmatch(log.text.String()); len(match) == 4 {
hours, _ := strconv.ParseFloat(match[1], 64)
minutes, _ := strconv.ParseFloat(match[2], 64)
seconds, _ := strconv.ParseFloat(match[3], 64)
log.duration = hours*3600 + minutes*60 + seconds
}
}
if log.mime != "" || strings.Contains(log.text.String(), "Output #0") {
log.once.Do(func() { close(log.ready) })
}
return len(data), nil
}
func (log *playbackLog) snapshot() (float64, string) {
log.mu.Lock()
defer log.mu.Unlock()
return log.duration, log.text.String()
}
func (log *playbackLog) mimeType() string {
log.mu.Lock()
defer log.mu.Unlock()
return firstNonEmpty(log.mime, playbackMIME)
}
func (downloader *Downloader) resolvePlaybackMedia(ctx context.Context, task Task) (providerMedia, []byte, error) {
if isHuangguoProviderSource(task.Chapter.Source) || task.Chapter.PageURL != "" || isProviderHTTPMediaURL(task.Chapter.VideoURL) || strings.HasPrefix(task.Chapter.VideoURL, "hongguo-cenc://") {
media, err := downloader.resolveProviderMedia(ctx, task)
return media, nil, err
}
if task.Chapter.VideoURL == "" {
return providerMedia{}, nil, errors.New("此分集没有可用的播放地址")
}
var key []byte
if downloader.cfg.AESKeyHex != "" {
var err error
key, err = hex.DecodeString(downloader.cfg.AESKeyHex)
if err != nil || len(key) != aes.BlockSize {
return providerMedia{}, nil, errors.New("aesKeyHex 必须是 16 字节 AES 密钥的 hex 编码")
}
}
base, err := downloader.apiEndpoint(ctx)
if err != nil {
return providerMedia{}, nil, err
}
access, err := downloader.legacyCredentials(ctx)
if err != nil {
return providerMedia{}, nil, err
}
mediaURL := fmt.Sprintf("%s/api/app/vid/h5/m3u8/%s?token=%s&c=%s", base, strings.TrimLeft(task.Chapter.VideoURL, "/"), url.QueryEscape(access.Token), url.QueryEscape(downloader.cfg.CDNURL))
playlist, err := downloader.fetchRaw(ctx, mediaURL)
if err != nil {
return providerMedia{}, nil, err
}
if !strings.HasPrefix(strings.TrimSpace(playlist), "#EXTM3U") {
return providerMedia{}, nil, errors.New("站点未返回有效的播放列表")
}
return providerMedia{URL: mediaURL, Playlist: playlist, Duration: m3u8Duration(playlist), Referer: legacyFrontendURL + "/"}, key, nil
}
func playbackInputArgs(media providerMedia, input string, offset float64) []string {
args := []string{"-hide_banner", "-loglevel", "info", "-nostats", "-nostdin", "-threads", "2", "-filter_threads", "1", "-filter_complex_threads", "1"}
if isProviderHTTPMediaURL(input) {
args = append(args, "-rw_timeout", "20000000", "-protocol_whitelist", "http,https,tcp,tls,crypto,httpproxy")
} else {
args = append(args, "-protocol_whitelist", "file,pipe")
}
if media.Playlist != "" {
args = append(args, "-allowed_extensions", "ALL")
}
if len(media.CENCKey) > 0 {
args = append(args, "-decryption_key", hex.EncodeToString(media.CENCKey))
}
if offset > 0 {
args = append(args, "-ss", strconv.FormatFloat(offset, 'f', 3, 64))
}
return append(args, "-i", input, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-map_metadata", "-1")
}
func playbackEncodingArgs(media providerMedia, input string, offset float64) []string {
return append(playbackInputArgs(media, input, offset),
"-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,setsar=1",
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", "-profile:v", "baseline",
"-pix_fmt", "yuv420p", "-crf", "22", "-maxrate", "12000k", "-bufsize", "24000k", "-threads", "4",
"-g", "30", "-keyint_min", "30", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2")
}
func playbackFFmpegArgs(media providerMedia, input string, offset float64) []string {
return append(playbackEncodingArgs(media, input, offset), "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", "-f", "mp4", "pipe:1")
}
func playbackRemuxArgs(media providerMedia, input string) []string {
return append(playbackInputArgs(media, input, 0), "-c", "copy", "-movflags", "+frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", "-f", "mp4", "pipe:1")
}
func (app *UIApp) preparePlaybackMedia(ctx context.Context, task Task, downloadID string) (providerMedia, string, *hlsProxy, error) {
var input string
if downloadID != "" {
var err error
task, input, err = app.playbackCollectionTask(downloadID)
if err != nil {
return providerMedia{}, "", nil, err
}
}
var media providerMedia
var proxy *hlsProxy
if input == "" {
var key []byte
var err error
media, key, err = app.downloader.resolvePlaybackMedia(ctx, task)
if err != nil {
return providerMedia{}, "", nil, fmt.Errorf("播放地址解析失败:%w", err)
}
media, err = app.downloader.selectPlaybackQuality(ctx, media)
if err != nil {
return providerMedia{}, "", nil, fmt.Errorf("清晰度解析失败:%w", err)
}
if len(media.CENCKey) != 0 && (len(media.CENCKey) != aes.BlockSize || media.Playlist != "") {
return providerMedia{}, "", nil, errors.New("播放密钥或媒体格式无效")
}
proxy, err = app.downloader.newHLSProxy(ctx, media, key)
if err != nil {
return providerMedia{}, "", nil, err
}
proxy.acquire = func(request context.Context) (func(), error) {
optional, _ := ctx.Value(playbackPrefetchKey{}).(bool)
return app.mediaResources().acquire(request, "media", optional)
}
input = proxy.root
}
return media, input, proxy, nil
}
func (app *UIApp) streamPlayback(ctx context.Context, cancel context.CancelFunc, writer http.ResponseWriter, task Task, downloadID string, offset float64, run uint64, ready func(float64)) (resultErr error) {
started := false
defer func() {
if resultErr != nil && !started {
writeJSON(writer, http.StatusBadGateway, map[string]string{"error": app.redactError(resultErr)})
}
}()
media, input, proxy, err := app.preparePlaybackMedia(ctx, task, downloadID)
if err != nil {
return err
}
if proxy != nil {
defer proxy.Close()
}
ffmpeg, err := app.downloader.ensureFFmpeg(ctx)
if err != nil {
return err
}
remux, _ := ctx.Value(playbackRemuxKey{}).(bool)
remux = remux && offset == 0
var process *playbackProcess
buffer := make([]byte, 64*1024)
var count int
var readErr error
for {
args := playbackFFmpegArgs(media, input, offset)
if remux {
args = playbackRemuxArgs(media, input)
}
if optional, _ := ctx.Value(playbackPrefetchKey{}).(bool); optional {
for index := 0; index+1 < len(args); index++ {
if args[index] == "-threads" {
args[index+1] = "1"
}
}
}
kind := "video"
if remux {
kind = "remux"
}
process, err = app.startBudgetedPlaybackProcess(ctx, ffmpeg, args, kind)
if err != nil {
return err
}
if !remux {
count, readErr = process.stdout.Read(buffer)
break
}
initial, mime, initErr := readPlaybackMP4Init(process.stdout)
if initErr == nil && mime != "" {
if len(initial) > len(buffer) {
buffer = make([]byte, len(initial))
}
count = copy(buffer, initial)
process.log.mu.Lock()
process.log.mime = mime
process.log.mu.Unlock()
break
}
process.Close()
if ctx.Err() != nil || proxy != nil && proxy.Err() != nil {
return playbackStreamError(ctx, proxy, process.log, media, nil, initErr)
}
remux = false
}
defer cancel()
defer process.Close()
log := process.log
if count == 0 {
waitErr := process.Wait()
return playbackStreamError(ctx, proxy, log, media, waitErr, readErr)
}
select {
case <-log.ready:
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
duration, _ := log.snapshot()
if duration <= 0 {
duration = media.Duration.Seconds()
}
if duration > 0 && offset >= duration {
return errors.New("播放位置超过本集时长")
}
ready(duration)
writer.Header().Set("Content-Type", "video/mp4")
writer.Header().Set("Content-Disposition", "inline")
writer.Header().Set("X-Playback-Duration", strconv.FormatFloat(duration, 'f', 3, 64))
writer.Header().Set("X-Playback-Run", strconv.FormatUint(run, 10))
writer.Header().Set("X-Playback-MIME", log.mimeType())
mode := "transcode"
if remux {
mode = "remux"
}
writer.Header().Set("X-Playback-Mode", mode)
app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.mode", Source: sourceFromDramaID(task.DramaID),
DramaID: task.DramaID, DramaTitle: task.DramaTitle, Episode: task.Index, Run: run, StartSeconds: offset,
Message: "MSE " + mode + " " + log.mimeType()})
setPlaybackQualityHeaders(writer.Header(), media)
if proxy == nil {
writer.Header().Set("X-Playback-Source", "local")
} else {
writer.Header().Set("X-Playback-Source", "online")
}
controller := http.NewResponseController(writer)
interrupted := make(chan struct{})
stopInterrupt := context.AfterFunc(ctx, func() {
_ = controller.SetWriteDeadline(time.Now())
close(interrupted)
})
defer func() {
if !stopInterrupt() {
<-interrupted
}
_ = controller.SetWriteDeadline(time.Time{})
}()
started = true
for count > 0 {
if _, err := writer.Write(buffer[:count]); err != nil {
return err
}
if err := controller.Flush(); err != nil {
return err
}
count, readErr = process.stdout.Read(buffer)
}
if readErr != nil && readErr != io.EOF {
cancel()
}
waitErr := process.Wait()
if waitErr != nil || readErr != nil && readErr != io.EOF {
return playbackStreamError(ctx, proxy, log, media, waitErr, readErr)
}
if proxy != nil {
if err := proxy.Err(); err != nil {
return fmt.Errorf("读取在线媒体失败:%w", err)
}
}
return nil
}
func playbackStreamError(ctx context.Context, proxy *hlsProxy, log *playbackLog, media providerMedia, commandErr, readErr error) error {
if ctx.Err() != nil {
return errors.New("播放请求已停止或准备超时,请重试")
}
if proxy != nil {
if err := proxy.Err(); err != nil {
return fmt.Errorf("读取在线媒体失败:%w", err)
}
}
_, detail := log.snapshot()
if strings.Contains(detail, "Unknown encoder") {
return errors.New("此 FFmpeg 缺少 libx264 或 AAC 编码器,请安装完整版本后重试")
}
lines := strings.Split(strings.TrimSpace(detail), "\n")
if len(lines) > 8 {
lines = lines[len(lines)-8:]
}
detail = strings.Join(lines, "\n")
if len(media.CENCKey) > 0 {
detail = strings.ReplaceAll(detail, hex.EncodeToString(media.CENCKey), "[redacted]")
}
if detail == "" {
detail = fmt.Sprint(firstNonNilError(commandErr, readErr, errors.New("没有收到可播放的视频数据")))
}
return publicError(fmt.Errorf("在线播放转码失败:%s", detail))
}
func firstNonNilError(candidates ...error) error {
for _, candidate := range candidates {
if candidate != nil {
return candidate
}
}
return nil
}