Files
djk/internal/app/ui_playback.go
T

524 lines
18 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"io"
"math"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const playbackIdleTimeout = 10 * time.Minute
const playbackSessionLimit = 4
const playbackMIME = `video/mp4; codecs="avc1.42C01F, mp4a.40.2"`
type playbackSession struct {
accountID string
dramaID string
id string
viewer *viewerRecords
tasks []Task
downloadIDs []string
prepared map[int]bool
expires time.Time
timer *time.Timer
cancel context.CancelFunc
run uint64
state string
error string
duration float64
currentIndex int
prefetchVersion uint64
prefetch *playbackPrefetch
native *playbackNative
media *playbackMediaSession
mediaReady chan struct{}
mediaError error
mediaWaiters int
quality int
streamVersion uint64
openedAt time.Time
historySequence uint64
historyRuns map[uint64]playbackHistoryRun
}
type playbackEpisode struct {
VIP bool `json:"vip,omitempty"`
Index int `json:"index"`
Episode string `json:"episode"`
Title string `json:"title"`
TaskID string `json:"taskId,omitempty"`
Danmaku bool `json:"danmaku,omitempty"`
ChapterID string `json:"chapterId,omitempty"`
Number int `json:"number"`
Total int `json:"total"`
}
type playbackView struct {
State string `json:"state"`
Error string `json:"error,omitempty"`
Run uint64 `json:"run"`
Duration float64 `json:"duration"`
Prefetch *playbackPrefetchView `json:"prefetch,omitempty"`
Plan *playbackMediaPlan `json:"plan,omitempty"`
MediaBytes int64 `json:"mediaBytes,omitempty"`
MediaFailure bool `json:"mediaFailure,omitempty"`
}
func (app *UIApp) registerPlaybackRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/ui/admin/playback", app.handlePlaybackSettings)
mux.HandleFunc("/api/ui/playback/plan", app.handlePlaybackPlan)
mux.HandleFunc("/api/ui/playback/media/", app.handlePlaybackMediaAsset)
mux.HandleFunc("/api/emby/media/", app.handleEmbyMediaAsset)
mux.HandleFunc("/api/ui/playback/open", app.handlePlaybackOpen)
mux.HandleFunc("/api/ui/playback/prepare", app.handlePlaybackPrepare)
mux.HandleFunc("/api/ui/playback/stream", app.handlePlaybackStream)
mux.HandleFunc("/api/ui/playback/hls/open", app.handlePlaybackNativeOpen)
mux.HandleFunc("/api/ui/playback/hls/index.m3u8", app.handlePlaybackNativeAsset)
mux.HandleFunc("/api/ui/playback/hls/segment.ts", app.handlePlaybackNativeAsset)
mux.HandleFunc("/api/ui/playback/control", app.handlePlaybackControl)
mux.HandleFunc("/api/ui/playback/status", app.handlePlaybackStatus)
mux.HandleFunc("/api/ui/playback/prefetch", app.handlePlaybackPrefetch)
mux.HandleFunc("/api/ui/playback/danmaku", app.handlePlaybackDanmaku)
mux.HandleFunc("/api/ui/playback/history", app.handlePlaybackHistory)
mux.HandleFunc("/api/ui/playback/history/remove", app.handlePlaybackHistoryRemove)
mux.HandleFunc("/api/ui/playback/progress", app.handlePlaybackProgress)
}
func playbackRequestAllowed(writer http.ResponseWriter, request *http.Request, method string) bool {
writer.Header().Set("Cache-Control", "private, no-store, no-transform")
writer.Header().Set("X-Accel-Buffering", "no")
writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
writer.Header().Set("X-Content-Type-Options", "nosniff")
if request.Method != method {
writer.Header().Set("Allow", method)
writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "请求方法不支持"})
return false
}
switch request.Header.Get("Sec-Fetch-Site") {
case "same-origin", "none":
return true
case "":
default:
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "请从剧库页面发起播放"})
return false
}
if origin := request.Header.Get("Origin"); origin != "" {
parsed, err := url.Parse(origin)
if err != nil || parsed.User != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || !originHostMatches(parsed, request.Host) {
writeJSON(writer, http.StatusForbidden, map[string]string{"error": "不允许跨站播放请求"})
return false
}
}
return true
}
func originHostMatches(origin *url.URL, host string) bool {
port := ":80"
if origin.Scheme == "https" {
port = ":443"
}
return strings.EqualFold(strings.TrimSuffix(origin.Host, port), strings.TrimSuffix(host, port))
}
func readPlaybackRequest(writer http.ResponseWriter, request *http.Request, destination any) bool {
if !playbackRequestAllowed(writer, request, http.MethodPost) {
return false
}
contentType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
if contentType != "application/json" {
writeJSON(writer, http.StatusUnsupportedMediaType, map[string]string{"error": "请求必须使用 JSON"})
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 4096))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放请求无效"})
return false
}
if err := decoder.Decode(new(any)); err != io.EOF {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "播放请求包含多余数据"})
return false
}
return true
}
func (app *UIApp) handlePlaybackOpen(writer http.ResponseWriter, request *http.Request) {
var input struct {
DramaID string `json:"dramaId"`
TaskID string `json:"taskId"`
Resume bool `json:"resume"`
FromHistory bool `json:"fromHistory"`
}
if !readPlaybackRequest(writer, request, &input) {
return
}
if input.TaskID != "" && !requireDownload(writer, request) {
return
}
if input.TaskID != "" && input.DramaID != "" {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "请选择剧库播放或合集播放"})
return
}
viewer := requestViewer(writer, request)
if viewer == nil {
return
}
if input.DramaID != "" && !app.requireDramaSources(writer, request, []string{input.DramaID}) {
return
}
openedAt := time.Now()
history := viewer.playbackHistory()
previous, hasPrevious := history.get(input.DramaID)
if input.FromHistory && (!input.Resume || !hasPrevious || input.TaskID != "") {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "此观看记录已移除,请从剧库重新选择"})
return
}
var drama Drama
var title string
var tasks []Task
var downloadIDs []string
initialIndex := 1
var collectionErr error
app.mu.Lock()
if input.FromHistory && previous.Mode == "collection" && downloadAllowed(request.Context()) {
candidate := app.tasks[previous.TaskID]
if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID {
input.TaskID = candidate.ID
} else {
for _, taskID := range app.taskOrder {
candidate = app.tasks[taskID]
if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID {
input.TaskID = candidate.ID
break
}
}
}
}
if input.TaskID != "" {
if !app.requireTaskSourcesLocked(writer, request, taskSelection{IDs: []string{input.TaskID}}) {
app.mu.Unlock()
return
}
title, tasks, downloadIDs, initialIndex, collectionErr = app.collectionPlaybackTasksLocked(input.TaskID)
} else {
for _, candidate := range app.dramas {
if candidate.ID == input.DramaID && input.DramaID != "" {
drama = candidate
break
}
}
}
app.mu.Unlock()
if input.TaskID != "" && len(tasks) > 0 && input.Resume {
previous, hasPrevious = history.get(tasks[0].DramaID)
}
if input.TaskID == "" && drama.ID == "" && input.Resume && hasPrevious {
drama = Drama{ID: previous.DramaID, Source: previous.Source, Title: previous.Title}
}
if collectionErr != nil {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": app.redactError(collectionErr)})
return
}
if input.TaskID == "" && drama.ID == "" {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "此短剧不在当前剧库中,请刷新剧库后重试"})
return
}
if drama.ID != "" && !dramaAllowed(request.Context(), drama.ID, drama.Source) {
writeSourceDenied(writer)
return
}
for _, task := range tasks {
if !taskSourceAllowed(request.Context(), task) {
writeSourceDenied(writer)
return
}
}
sessionDramaID := drama.ID
if len(tasks) > 0 {
sessionDramaID = tasks[0].DramaID
}
ctx, cancel := context.WithTimeout(request.Context(), 90*time.Second)
defer cancel()
id := randomHex(24)
if id == "" {
writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "无法创建播放会话"})
return
}
app.playbackMu.Lock()
if len(app.playbacks) >= app.mediaResources().snapshot().MaxSessions {
app.playbackMu.Unlock()
writeJSON(writer, http.StatusTooManyRequests, map[string]string{"error": "同时播放数量已达上限,请稍后再试"})
return
}
if app.playbacks == nil {
app.playbacks = make(map[string]*playbackSession)
}
session := &playbackSession{downloadIDs: downloadIDs, dramaID: sessionDramaID, id: id, viewer: viewer, expires: time.Now().Add(playbackIdleTimeout), state: "opening", cancel: cancel, openedAt: openedAt, historyRuns: make(map[uint64]playbackHistoryRun)}
viewer.retain()
app.playbacks[id] = session
session.timer = time.AfterFunc(playbackIdleTimeout, func() { app.expirePlayback(id) })
app.playbackMu.Unlock()
ready := false
defer func() {
if !ready {
app.closePlayback(id)
}
}()
if input.TaskID == "" {
fetchedTitle, chapters, err := app.downloader.GetDramaChapters(ctx, drama.ID)
if err == nil && len(chapters) == 0 {
err = errors.New("站点未返回可播放的分集")
}
if err != nil {
writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "获取选集失败:" + app.redactError(err)})
return
}
title = firstNonEmpty(fetchedTitle, drama.DisplayTitle())
for index, chapter := range chapters {
tasks = append(tasks, Task{DramaID: drama.ID, DramaTitle: title, Chapter: chapter, Index: index + 1, Total: len(chapters)})
}
}
for _, task := range tasks {
if !taskSourceAllowed(request.Context(), task) {
writeSourceDenied(writer)
return
}
}
episodes := make([]playbackEpisode, 0, len(tasks))
for index, task := range tasks {
number := task.Index
if number < 1 {
number = index + 1
}
total := task.Total
if total < len(tasks) {
total = len(tasks)
}
if total < number {
total = number
}
episode := playbackEpisode{VIP: task.Chapter.VIP, Index: index + 1, Episode: task.Chapter.EpisodeString(number), Title: task.Chapter.Title, ChapterID: task.Chapter.ID, Number: number, Total: total}
_, _, episode.Danmaku = hongguoPlaybackIDs(task)
if len(downloadIDs) > 0 {
episode.TaskID = downloadIDs[index]
}
episodes = append(episodes, episode)
}
app.playbackMu.Lock()
if app.playbacks[id] == session && ctx.Err() == nil {
session.tasks = tasks
session.downloadIDs = downloadIDs
session.prepared = make(map[int]bool)
session.cancel = nil
session.state = "ready"
app.touchPlaybackLocked(session)
ready = true
}
app.playbackMu.Unlock()
if !ready {
writeJSON(writer, http.StatusRequestTimeout, map[string]string{"error": "播放准备超时,请重新打开本剧"})
return
}
mode := "online"
if len(downloadIDs) > 0 {
mode = "collection"
}
initialPosition, resumePaused, resumeMessage := float64(0), false, ""
if input.Resume && hasPrevious {
initialIndex, initialPosition, resumePaused, resumeMessage = playbackHistoryResume(previous, tasks, initialIndex)
}
dramaID, source, _ := playbackHistoryIdentity(tasks[0].DramaID)
writeJSON(writer, http.StatusOK, map[string]any{"session": id, "dramaId": dramaID, "source": source, "title": title, "releaseStatus": dramaReleaseStatus(drama), "vip": drama.VIP, "episodes": episodes, "mimeType": playbackMIME, "mode": mode, "initialIndex": initialIndex, "initialPosition": initialPosition, "resumePaused": resumePaused, "resumeMessage": resumeMessage})
}
func (app *UIApp) touchPlaybackLocked(session *playbackSession) {
session.expires = time.Now().Add(playbackIdleTimeout)
if session.timer != nil {
session.timer.Reset(playbackIdleTimeout)
}
}
func (app *UIApp) expirePlayback(id string) {
app.playbackMu.Lock()
session := app.playbacks[id]
if session != nil && time.Now().Before(session.expires) {
session.timer.Reset(time.Until(session.expires))
app.playbackMu.Unlock()
return
}
if session != nil {
delete(app.playbacks, id)
}
app.playbackMu.Unlock()
if session != nil && session.cancel != nil {
session.cancel()
}
if session != nil && session.prefetch != nil {
session.prefetch.cancel()
}
if session != nil {
session.viewer.release()
}
}
func (app *UIApp) closePlayback(id string) {
app.playbackMu.Lock()
session := app.playbacks[id]
if session != nil {
delete(app.playbacks, id)
session.timer.Stop()
}
app.playbackMu.Unlock()
if session != nil && session.cancel != nil {
session.cancel()
}
if session != nil && session.prefetch != nil {
session.prefetch.cancel()
}
if session != nil {
session.viewer.release()
}
}
func (app *UIApp) closePlaybacks() {
app.playbackMu.Lock()
sessions := app.playbacks
app.playbacks = nil
for _, session := range sessions {
session.timer.Stop()
}
app.playbackMu.Unlock()
for _, session := range sessions {
session.viewer.release()
if session.cancel != nil {
session.cancel()
}
if session.prefetch != nil {
session.prefetch.cancel()
}
}
}
func (app *UIApp) playbackStatus(id string, touch bool) (playbackView, bool) {
app.playbackMu.Lock()
defer app.playbackMu.Unlock()
session := app.playbacks[id]
if session == nil {
return playbackView{}, false
}
if touch {
app.touchPlaybackLocked(session)
}
view := playbackView{State: session.state, Error: session.error, Run: session.run, Duration: session.duration}
if session.native != nil {
state, err := session.native.state()
view.State = state
if err != nil {
view.Error = app.redactError(err)
}
}
if session.media != nil {
plan := session.media.plan
view.Plan = &plan
view.MediaBytes = session.media.bytes.Load()
view.MediaFailure = session.media.failed.Load() || session.media.proxy != nil && session.media.proxy.Err() != nil
}
if session.prefetch != nil {
view.Prefetch = session.prefetch.view()
}
return view, true
}
func (app *UIApp) handlePlaybackControl(writer http.ResponseWriter, request *http.Request) {
var input struct {
Session string `json:"session"`
Action string `json:"action"`
Progress *playbackHistoryProgress `json:"progress,omitempty"`
}
if !readPlaybackRequest(writer, request, &input) {
return
}
if !app.requirePlaybackOwner(writer, request, input.Session) {
return
}
if input.Action == "close" {
var progressError string
if input.Progress != nil {
if _, _, err := app.recordPlaybackProgress(contextViewer(request.Context()), input.Session, *input.Progress); err != nil {
progressError = publicError(err).Error()
}
}
app.closePlayback(input.Session)
writeJSON(writer, http.StatusOK, map[string]any{"ok": true, "historyError": progressError})
return
}
if input.Action != "heartbeat" {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "不支持此播放操作"})
return
}
if state, ok := app.playbackStatus(input.Session, true); ok {
writeJSON(writer, http.StatusOK, state)
} else {
writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"})
}
}
func (app *UIApp) handlePlaybackStatus(writer http.ResponseWriter, request *http.Request) {
if !playbackRequestAllowed(writer, request, http.MethodGet) {
return
}
if !app.requirePlaybackOwner(writer, request, request.URL.Query().Get("session")) {
return
}
if state, ok := app.playbackStatus(request.URL.Query().Get("session"), false); ok {
writeJSON(writer, http.StatusOK, state)
} else {
writeJSON(writer, http.StatusGone, map[string]string{"error": "播放会话已过期,请重新打开本剧"})
}
}
func (app *UIApp) handlePlaybackStream(writer http.ResponseWriter, request *http.Request) {
if !playbackRequestAllowed(writer, request, http.MethodGet) {
return
}
query := request.URL.Query()
index, indexErr := strconv.Atoi(query.Get("episode"))
offset, offsetErr := strconv.ParseFloat(firstNonEmpty(query.Get("start"), "0"), 64)
if indexErr != nil || index < 1 || offsetErr != nil || math.IsNaN(offset) || math.IsInf(offset, 0) || offset < 0 || offset > 24*60*60 {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "集数或播放位置无效"})
return
}
quality, qualityErr := parsePlaybackQuality(query.Get("quality"))
version, versionErr := strconv.ParseUint(firstNonEmpty(query.Get("version"), "0"), 10, 64)
if qualityErr != nil || versionErr != nil {
writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "清晰度或播放请求编号无效"})
return
}
ctx := context.WithValue(request.Context(), playbackRemuxKey{}, query.Get("remux") == "1")
run, status, err := app.beginPlayback(ctx, query.Get("session"), index, offset, quality, version, false)
if err != nil {
writeJSON(writer, status, map[string]string{"error": err.Error()})
return
}
defer run.stop()
writer = &playbackActivityWriter{ResponseWriter: writer, app: app, run: run}
startupTimer := time.AfterFunc(90*time.Second, run.cancel)
defer startupTimer.Stop()
ready := func(duration float64) {
startupTimer.Stop()
app.playbackRunReady(run, duration)
}
used, err := app.servePrefetchedPlayback(run.ctx, writer, run.cache, run.run, ready)
if !used {
err = app.streamPlayback(run.ctx, run.cancel, writer, run.task, run.downloadID, offset, run.run, ready)
}
app.finishPlaybackRun(run, err, request.Context().Err() != nil)
}