Files
djk/internal/app/ui_playback_plan.go
T

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)
}