354 lines
11 KiB
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
|
|
}
|