282 lines
9.4 KiB
Go
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)
|
|
}
|