Files
djk/internal/app/playback_gateway.go
T

282 lines
9.4 KiB
Go

package app
import (
"context"
"errors"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type playbackResponseURLs struct{ values sync.Map }
type playbackResponseURLsKey struct{}
func rememberPlaybackResponseURL(ctx context.Context, original string, response *http.Response) {
if urls, _ := ctx.Value(playbackResponseURLsKey{}).(*playbackResponseURLs); urls != nil && response.Request != nil && response.Request.URL != nil {
urls.values.Store(original, response.Request.URL.String())
}
}
type playbackMediaPlan struct {
URL string `json:"url"`
Run uint64 `json:"run"`
Delivery string `json:"delivery"`
Processing string `json:"processing"`
Player string `json:"player"`
MIME string `json:"mime"`
Reason string `json:"reason"`
Source string `json:"source"`
Duration float64 `json:"duration"`
Quality int `json:"quality"`
Qualities []playbackQualityOption `json:"qualities"`
DirectURL string `json:"directURL,omitempty"`
SeekEnd float64 `json:"seekEnd"`
}
type playbackMediaSession struct {
ctx context.Context
cancel context.CancelFunc
media providerMedia
key []byte
local string
proxy *hlsProxy
plan playbackMediaPlan
info playbackMediaInfo
generated *playbackGenerated
bytes atomic.Int64
failed atomic.Bool
started time.Time
closeOnce sync.Once
}
func (media *playbackMediaSession) Close() {
media.closeOnce.Do(func() {
media.cancel()
if media.generated != nil {
media.generated.Close()
}
if media.proxy != nil {
media.proxy.Close()
}
})
}
func (app *UIApp) resolveMediaSession(ctx, preparation context.Context, task Task, downloadID string, quality int, prefetched ...*playbackPrefetch) (*playbackMediaSession, error) {
ctx, cancel := context.WithCancel(context.WithValue(ctx, playbackQualityKey{}, quality))
result := &playbackMediaSession{ctx: ctx, cancel: cancel, started: time.Now()}
success := false
defer func() {
if !success {
result.Close()
}
}()
if downloadID != "" {
var err error
task, result.local, err = app.playbackCollectionTask(downloadID)
if err != nil {
return nil, err
}
}
usedPrefetch := false
if result.local == "" && len(prefetched) > 0 && prefetched[0] != nil && prefetched[0].planOnly {
cache := prefetched[0]
select {
case <-cache.done:
case <-preparation.Done():
return nil, preparation.Err()
}
cache.mu.Lock()
if cache.resolved != nil && cache.err == nil && cache.resolved.local == "" {
result.media, result.key, usedPrefetch = cache.resolved.media, cache.resolved.key, true
}
cache.mu.Unlock()
}
if result.local == "" && !usedPrefetch {
urls := &playbackResponseURLs{}
resolution := context.WithValue(context.WithValue(preparation, playbackQualityKey{}, quality), playbackResponseURLsKey{}, urls)
var err error
result.media, result.key, err = app.downloader.resolvePlaybackMedia(resolution, task)
if err != nil {
return nil, err
}
if final, ok := urls.values.Load(result.media.URL); ok {
result.media.URL = final.(string)
}
result.media, err = app.downloader.selectPlaybackQuality(resolution, result.media)
if err != nil {
return nil, err
}
if len(result.media.CENCKey) != 0 && (len(result.media.CENCKey) != 16 || result.media.Playlist != "") {
return nil, errors.New("播放密钥或媒体格式无效")
}
}
result.plan = playbackMediaPlan{Delivery: "proxy", Processing: "original", Player: "mp4", Source: "online",
MIME: "video/mp4", Reason: "compatible_original", Duration: result.media.Duration.Seconds(),
Quality: result.media.Quality, Qualities: playbackQualityOptions(result.media)}
if result.local != "" {
result.plan.Delivery, result.plan.Source = "local", "local"
}
if result.media.Playlist != "" {
result.plan.Player, result.plan.MIME = "hls", "application/vnd.apple.mpegurl"
}
result.plan.SeekEnd = result.plan.Duration
success = true
return result, nil
}
func (app *UIApp) attachMediaGateway(media *playbackMediaSession, base, query string) {
proxy := &hlsProxy{client: &http.Client{Transport: app.downloader.client.Transport}, base: base, referer: media.media.Referer,
key: media.key, mediaKey: media.media.HLSKey, assets: map[string]hlsAsset{}, assetIDs: map[string]string{},
retries: app.downloader.cfg.Retries, diagnostic: app.downloader.recordDiagnostic, query: query,
acquire: func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) }}
if remote, err := url.Parse(media.media.URL); err == nil {
proxy.host = remote.Hostname()
}
proxy.root = proxy.addAsset(playbackRootAsset(media.media))
if media.plan.Player == "mp4" {
proxy.root = base + "file.mp4"
if query != "" {
proxy.root += "?" + query
}
proxy.assets = map[string]hlsAsset{base + "file.mp4": {remote: media.media.URL}}
proxy.assetIDs = map[string]string{media.media.URL: proxy.root}
}
media.proxy = proxy
media.plan.URL = proxy.root
if media.local != "" {
media.plan.URL = base + "file.mp4"
if query != "" {
media.plan.URL += "?" + query
}
}
}
type playbackCountingWriter struct {
http.ResponseWriter
media *playbackMediaSession
activity func()
}
func (writer *playbackCountingWriter) Unwrap() http.ResponseWriter { return writer.ResponseWriter }
func (writer *playbackCountingWriter) WriteHeader(status int) {
if status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound || status == http.StatusGone || status == http.StatusTooManyRequests || status >= 500 {
writer.media.failed.Store(true)
}
writer.ResponseWriter.WriteHeader(status)
}
func (writer *playbackCountingWriter) Write(body []byte) (int, error) {
n, err := writer.ResponseWriter.Write(body)
writer.media.bytes.Add(int64(n))
if n > 0 && writer.activity != nil {
writer.activity()
}
return n, err
}
func (app *UIApp) serveMediaSession(writer http.ResponseWriter, request *http.Request, media *playbackMediaSession) {
if request.Method != http.MethodGet && request.Method != http.MethodHead {
writer.Header().Set("Allow", "GET, HEAD")
writer.WriteHeader(http.StatusMethodNotAllowed)
return
}
if media.ctx.Err() != nil {
writer.WriteHeader(http.StatusGone)
return
}
ctx, cancel := context.WithCancel(request.Context())
defer cancel()
idle := time.AfterFunc(60*time.Second, cancel)
defer idle.Stop()
stop := context.AfterFunc(media.ctx, cancel)
defer stop()
request = request.WithContext(ctx)
writer.Header().Set("Cache-Control", "private, no-store, no-transform")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Accel-Buffering", "no")
controller := http.NewResponseController(writer)
interrupted := make(chan struct{})
interrupt := context.AfterFunc(ctx, func() { _ = controller.SetWriteDeadline(time.Now()); close(interrupted) })
defer func() {
if !interrupt() {
<-interrupted
}
_ = controller.SetWriteDeadline(time.Time{})
}()
writer = &playbackCountingWriter{ResponseWriter: writer, media: media, activity: func() { idle.Reset(60 * time.Second) }}
if media.plan.Delivery == "redirect" {
if request.URL.Path != strings.Split(media.plan.URL, "?")[0] {
http.NotFound(writer, request)
return
}
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Location", media.media.URL)
writer.WriteHeader(http.StatusFound)
return
}
if media.generated != nil {
media.generated.ServeHTTP(writer, request)
return
}
if media.local != "" {
if request.URL.Path != strings.Split(media.plan.URL, "?")[0] {
http.NotFound(writer, request)
return
}
file, err := os.Open(media.local)
if err != nil {
http.NotFound(writer, request)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
http.NotFound(writer, request)
return
}
writer.Header().Set("Content-Type", "video/mp4")
http.ServeContent(writer, request, "file.mp4", info.ModTime(), file)
return
}
if len(request.Header.Get("Range")) > 256 || strings.Contains(request.Header.Get("Range"), ",") {
writer.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
media.proxy.ServeHTTP(writer, request)
}
func (app *UIApp) handlePlaybackMediaAsset(writer http.ResponseWriter, request *http.Request) {
if !playbackRequestAllowed(writer, request, request.Method) {
return
}
parts := strings.Split(strings.TrimPrefix(request.URL.Path, "/api/ui/playback/media/"), "/")
if len(parts) != 3 {
http.NotFound(writer, request)
return
}
run, err := strconv.ParseUint(parts[1], 10, 64)
viewer := contextViewer(request.Context())
app.playbackMu.Lock()
session := app.playbacks[parts[0]]
denied := err != nil || session == nil || session.run != run || session.media == nil
if !denied && viewer != nil {
denied = !viewerOwnsPlayback(request.Context(), session)
}
if denied {
app.playbackMu.Unlock()
app.recordPlaybackAssetDenial(request, "session-unavailable")
writer.WriteHeader(http.StatusGone)
return
}
media := session.media
active := &playbackRunContext{id: session.id, run: session.run, session: session, ctx: media.ctx}
app.touchPlaybackLocked(session)
app.playbackMu.Unlock()
writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: active}
app.serveMediaSession(writer, request, media)
}