229 lines
8.2 KiB
Go
229 lines
8.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type playbackClientCapabilities struct {
|
|
MP4 bool `json:"mp4"`
|
|
NativeHLS bool `json:"nativeHls"`
|
|
HlsJS bool `json:"hlsjs"`
|
|
Video []string `json:"video"`
|
|
Audio []string `json:"audio"`
|
|
}
|
|
|
|
func playbackCodecAllowed(choices []string, codec string) bool {
|
|
if len(choices) == 0 || codec == "" {
|
|
return true
|
|
}
|
|
for _, choice := range choices {
|
|
if choice == codec {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var playbackProbeVideo = regexp.MustCompile(`Video: ([a-zA-Z0-9_]+)`)
|
|
var playbackProbeAudio = regexp.MustCompile(`Audio: ([a-zA-Z0-9_]+)`)
|
|
|
|
func (app *UIApp) probePlaybackCodecs(ctx context.Context, media *playbackMediaSession) error {
|
|
if media.info.Video != "" && media.info.Audio != "unknown" {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
|
defer cancel()
|
|
ffmpeg, err := app.downloader.ensureFFmpeg(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
release, err := app.mediaResources().acquire(ctx, "remux", false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer release()
|
|
input := media.local
|
|
var proxy *hlsProxy
|
|
if input == "" {
|
|
proxy, err = app.downloader.newHLSProxy(ctx, media.media, media.key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer proxy.Close()
|
|
proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) }
|
|
input = proxy.root
|
|
}
|
|
args := playbackInputArgs(media.media, input, 0)
|
|
for index, value := range args {
|
|
if value == "-i" {
|
|
args = append(args[:index], append([]string{"-probesize", "2097152", "-analyzeduration", "2000000"}, args[index:]...)...)
|
|
break
|
|
}
|
|
}
|
|
args = append(args, "-c", "copy", "-t", "0", "-f", "null", "-")
|
|
command := ffmpegMediaCommand(ctx, ffmpeg, args...)
|
|
log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})}
|
|
command.Stderr = log
|
|
err = command.Run()
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
duration, body := log.snapshot()
|
|
if value := playbackProbeVideo.FindStringSubmatch(body); len(value) == 2 {
|
|
media.info.Video = value[1]
|
|
}
|
|
if value := playbackProbeAudio.FindStringSubmatch(body); len(value) == 2 {
|
|
media.info.Audio = value[1]
|
|
}
|
|
if proxy != nil && proxy.Err() != nil || media.info.Video == "" && err != nil {
|
|
return playbackStreamError(ctx, proxy, log, media.media, err, nil)
|
|
}
|
|
if duration > 0 && duration != media.plan.Duration {
|
|
media.plan.Duration, media.plan.SeekEnd = duration, duration
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string, startSeconds float64) error {
|
|
app.attachMediaGateway(media, base, query)
|
|
app.inspectPlaybackMedia(ctx, media)
|
|
if mode == "legacy" {
|
|
media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "client_decode_failed"
|
|
return nil
|
|
}
|
|
convert := len(media.media.CENCKey) > 0 || mode == "compatible" || mode == "audio"
|
|
if !playbackCodecAllowed(client.Video, media.info.Video) {
|
|
convert = true
|
|
}
|
|
if !playbackCodecAllowed(client.Audio, media.info.Audio) {
|
|
convert = true
|
|
}
|
|
if convert {
|
|
if err := app.probePlaybackCodecs(ctx, media); err != nil {
|
|
return err
|
|
}
|
|
if !playbackCodecAllowed(client.Video, media.info.Video) || media.info.Video == "" {
|
|
media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "video_codec_unsupported"
|
|
return nil
|
|
}
|
|
processing := "remux"
|
|
media.plan.Reason = "container_compatibility"
|
|
if len(media.media.CENCKey) > 0 {
|
|
media.plan.Reason = "server_decryption_required"
|
|
}
|
|
if mode == "audio" || !playbackCodecAllowed(client.Audio, media.info.Audio) {
|
|
processing, media.plan.Reason = "audio", "audio_codec_unsupported"
|
|
}
|
|
if !client.NativeHLS && !client.HlsJS {
|
|
media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "hls_player_unavailable"
|
|
return nil
|
|
}
|
|
if err := app.generatePlayback(ctx, media, processing, base, query, startSeconds); err != nil {
|
|
return err
|
|
}
|
|
} else if media.plan.Player == "hls" && !client.NativeHLS && !client.HlsJS || media.plan.Player == "mp4" && !client.MP4 {
|
|
media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "original_player_unavailable"
|
|
return nil
|
|
}
|
|
if mode == "auto" && media.plan.Processing == "original" && app.allowPlaybackDirect(ctx, media, source, origin) {
|
|
media.plan.Delivery, media.plan.Reason = "redirect", "direct_probe_passed"
|
|
} else if mode == "proxy" {
|
|
media.plan.Reason = "client_direct_access_failed"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (app *UIApp) handlePlaybackPlan(writer http.ResponseWriter, request *http.Request) {
|
|
var input struct {
|
|
Session string `json:"session"`
|
|
Episode int `json:"episode"`
|
|
Start float64 `json:"start"`
|
|
Quality int `json:"quality"`
|
|
Version uint64 `json:"version"`
|
|
Mode string `json:"mode"`
|
|
Client playbackClientCapabilities `json:"client"`
|
|
}
|
|
if !readPlaybackRequest(writer, request, &input) {
|
|
return
|
|
}
|
|
if input.Episode < 1 || math.IsNaN(input.Start) || math.IsInf(input.Start, 0) || input.Start < 0 || input.Start > 24*60*60 || input.Quality < 0 || input.Quality > 4320 || len(input.Client.Video) > 16 || len(input.Client.Audio) > 16 {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "集数、播放位置或媒体能力无效"})
|
|
return
|
|
}
|
|
if input.Mode == "" {
|
|
input.Mode = "auto"
|
|
}
|
|
if input.Mode != "auto" && input.Mode != "proxy" && input.Mode != "compatible" && input.Mode != "audio" && input.Mode != "legacy" {
|
|
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放模式无效"})
|
|
return
|
|
}
|
|
if !app.requirePlaybackOwner(writer, request, input.Session) {
|
|
return
|
|
}
|
|
if !app.mediaResources().snapshot().Enabled {
|
|
app.playbackMu.Lock()
|
|
session := app.playbacks[input.Session]
|
|
merged := session != nil && input.Episode <= len(session.downloadIDs) && strings.HasPrefix(session.downloadIDs[input.Episode-1], mergedPlaybackPrefix)
|
|
app.playbackMu.Unlock()
|
|
if !merged {
|
|
writeJSON(writer, http.StatusOK, playbackMediaPlan{Player: "legacy", Reason: "optimization_disabled"})
|
|
return
|
|
}
|
|
}
|
|
run, status, err := app.beginPlayback(context.WithoutCancel(request.Context()), input.Session, input.Episode, input.Start, input.Quality, input.Version, false)
|
|
if err != nil {
|
|
writeJSON(writer, status, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
ready := false
|
|
var media *playbackMediaSession
|
|
defer func() {
|
|
if !ready {
|
|
run.stop()
|
|
if media != nil {
|
|
media.Close()
|
|
}
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(run.ctx, 90*time.Second)
|
|
defer cancel()
|
|
stop := context.AfterFunc(request.Context(), cancel)
|
|
defer stop()
|
|
media, err = app.resolveMediaSession(run.ctx, ctx, run.task, run.downloadID, input.Quality, run.cache)
|
|
if err == nil {
|
|
base := "/api/ui/playback/media/" + run.id + "/" + strconv.FormatUint(run.run, 10) + "/"
|
|
err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "", input.Start)
|
|
}
|
|
if err != nil {
|
|
app.finishPlaybackRun(run, err, request.Context().Err() != nil)
|
|
writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "准备播放失败:" + app.redactError(err)})
|
|
return
|
|
}
|
|
media.plan.Run = run.run
|
|
if media.plan.Player == "legacy" {
|
|
writeJSON(writer, http.StatusOK, media.plan)
|
|
return
|
|
}
|
|
app.playbackMu.Lock()
|
|
if app.playbacks[run.id] == run.session && run.session.run == run.run && ctx.Err() == nil {
|
|
run.session.media = media
|
|
run.session.cancel = func() { run.stop(); media.Close() }
|
|
ready = true
|
|
}
|
|
app.playbackMu.Unlock()
|
|
if !ready {
|
|
writeJSON(writer, http.StatusConflict, map[string]string{"error": "播放请求已更新"})
|
|
return
|
|
}
|
|
app.playbackRunReady(run, media.plan.Duration)
|
|
app.downloader.recordDiagnostic(diagnosticEvent{Level: "info", Event: "playback.plan", Source: sourceFromDramaID(run.task.DramaID), DramaID: run.task.DramaID,
|
|
Episode: input.Episode, Run: run.run, Quality: input.Quality, Message: strings.Join([]string{media.plan.Delivery, media.plan.Processing, media.plan.Player, media.plan.Reason}, " / ")})
|
|
writeJSON(writer, http.StatusOK, media.plan)
|
|
}
|