修复 115 云转码播放中途断流后直接掉到本地转码

115 云 HLS 会话只存在服务端内存里:服务重新部署、会话过期(30 分钟无活动)、
上游分片地址失效,都会让播放中的客户端在后续分片上拿到 404/410。原实现此时
直接退回本地转码,而低配宿主机本地转码追不上播放速度(实测 2 vCPU 只有
0.62x),用户看到的就是播放报废。

- 云 HLS 中途 fatal 时先重建会话续播(10 分钟内最多 3 次),仍失败才退回本地
- manifestLoadingMaxRetry 1 -> 4:master.m3u8 每次都要现调 115 接口,一次抖动
  不该直接判死刑
- 播放列表重写改用上游地址哈希做 key:原来用自增序号,每次重写 entries 就膨胀
  一轮、分片编号漂移(实测 762 段列表重写几次后 key 涨到 e2288..e3049)
- 会话条目数加上限,异常播放列表不会无限占用内存
- 客户端断开(seek / 切清晰度取消在途请求)不再记成 502,改记 499 与 nginx
  对齐;会话失效返回带 code 的 404/410,便于前端判断需要重建会话
- 修正进度/时长上报:云 HLS 时间轴起点恒为 0,不应再叠加 hlsStartSec(历史上
  把 7624s 的片子记成 8346s 并标成「已看完」)
- 切回直连 / 原画时保住播放位置:HLS 卸载会把 currentTime 归零,必须在切换前
  先记下目标位置
This commit is contained in:
truewhile
2026-10-08 22:50:45 +08:00
parent 67ef9c95be
commit 5f4b613bde
5 changed files with 236 additions and 21 deletions
@@ -0,0 +1,58 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// 云 HLS 分片代理的错误映射:
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
// 而不是当成播放失败直接退回本地转码;
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
func newCloud115HLSSessionTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
svc := &service.Container{
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
}
router := gin.New()
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
return router
}
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
}
}
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 499 {
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
if err == nil || c.Writer.Written() {
return
}
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
// 而不是直接放弃云端播放退回本地转码。
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+29 -4
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -12,6 +14,8 @@ import (
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
@@ -24,6 +28,10 @@ const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
cloud115HLSMaxEntries = 20000
)
type cloud115HLSSession struct {
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
mu sync.Mutex
entries map[string]string
next int
}
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
//
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
func cloud115HLSKeyForURL(upstream string) string {
sum := sha256.Sum256([]byte(upstream))
return hex.EncodeToString(sum[:12])
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
@@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
key := cloud115HLSKeyForURL(upstream)
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
_, known := session.entries[key]
if !known && len(session.entries) < cloud115HLSMaxEntries {
session.entries[key] = upstream
known = true
}
mediaID := session.MediaID
session.mu.Unlock()
if !known && p != nil && p.service != nil && p.service.log != nil {
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
}
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
+30 -4
View File
@@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
@@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
}
}
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
entriesAfterFirst := len(session.entries)
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
if first != second {
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
}
if len(session.entries) != entriesAfterFirst {
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
}
if entriesAfterFirst != 2 {
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
@@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
@@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
@@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
+106 -11
View File
@@ -81,6 +81,10 @@ type PlaybackProgressSession = {
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
function normalizePlayerVolume(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
@@ -185,6 +189,17 @@ export function PlayerPage() {
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const cloudRetryRef = useRef(5)
const pendingSeekRef = useRef<number | null>(null)
// 云 HLS 会话重建计数(时间戳)。115 云转码的会话只存在服务端内存里:服务
// 重新部署、会话过期(30 分钟无活动)、上游地址失效都会让播放中途拿到
// 404/410。这种情况下重新拉一次 master.m3u8 建个新会话即可续播,没必要直接
// 掉到本地转码(这台机器本地转码只有 0.6x,等于播放报废)。窗口内限制次数,
// 避免上游真的坏了时无限重连。
const cloudReloadsRef = useRef<number[]>([])
const [cloudReloadNonce, setCloudReloadNonce] = useState(0)
// 切回直连/原画时把当前位置带过去:HLS 卸载会把 video.currentTime 归零,
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
const pendingDirectStartRef = useRef(0)
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -488,6 +503,8 @@ export function PlayerPage() {
setCloudWaitMessage('')
setCloudWaitStartedAt(0)
pendingSeekRef.current = null
cloudReloadsRef.current = []
pendingDirectStartRef.current = 0
if (cloudPollTimerRef.current) {
clearTimeout(cloudPollTimerRef.current)
cloudPollTimerRef.current = null
@@ -953,6 +970,38 @@ export function PlayerPage() {
[playbackInfo, setPlaybackMode],
)
/**
* 115 云 HLS 中途断流后重建会话。
*
* 115 的云端转码会话只存在服务端内存里:服务重新部署、会话到期(30 分钟无活动)、
* 上游分片地址失效,都会让正在播放的客户端在后续分片上拿到 404/410。重新请求一次
* master.m3u8 就会建出新会话,从当前位置继续播即可——比直接退回本地转码好得多
* (低配宿主机本地转码往往追不上播放速度,等于不能看)。
*
* 窗口内超过次数上限返回 false,由调用方走本地转码兜底,避免上游真的坏了时无限重连。
*/
const reloadCloudHls = useCallback(
(position: number): boolean => {
const now = Date.now()
const recent = cloudReloadsRef.current.filter((at) => now - at < CLOUD_HLS_RELOAD_WINDOW_MS)
if (recent.length >= CLOUD_HLS_RELOAD_MAX) {
cloudReloadsRef.current = recent
return false
}
recent.push(now)
cloudReloadsRef.current = recent
// 云 HLS 是整条时间轴,起播后按绝对位置跳一下,别让用户回到片头。
pendingSeekRef.current = position > 2 ? position : null
setCloudWaiting(false)
setCloudWaitMessage('')
setHlsSource('cloud')
setPlaybackMode('hls')
setCloudReloadNonce((v) => v + 1)
return true
},
[setPlaybackMode],
)
const startCloudTranscode = useCallback(
async (definition: number) => {
if (!mediaId) return
@@ -1126,7 +1175,10 @@ export function PlayerPage() {
// hosts and remote STRM sources regularly need more than hls.js's
// 10s default, which otherwise aborts a healthy transcode.
manifestLoadingTimeOut: 60_000,
manifestLoadingMaxRetry: 1,
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
// 直接掉到本地转码。
manifestLoadingMaxRetry: 4,
})
try {
video.currentTime = 0
@@ -1160,6 +1212,11 @@ export function PlayerPage() {
if (data.fatal) {
if (hlsSource === 'cloud') {
const position = ref.current?.currentTime || 0
// 先尝试重建云会话续播;只有连着失败到上限才退回本地转码。
if (reloadCloudHls(position)) {
toast('115 云端播放中断,正在重新连接…', { duration: 2200 })
return
}
setHlsUnavailable(false)
switchToLocalHLS(position)
toast.error('115 云端播放失败,切换本地转码')
@@ -1211,12 +1268,24 @@ export function PlayerPage() {
if (video.src !== absoluteURL) {
directRetryRef.current = false
clearFallbackTimer()
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
// 保住当前位置。两个来源:显式切回直连/原画时提前记下的目标位置
// (HLS 卸载会把 video.currentTime 归零,所以不能只读 video.currentTime),
// 以及 VR 进出时切换播放源地址的情形。
const resumeAt = Math.max(
pendingDirectStartRef.current,
video.currentTime > 2 ? video.currentTime : 0,
)
pendingDirectStartRef.current = 0
if (resumeAt > 0) {
video.addEventListener(
'loadedmetadata',
() => {
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
if (isStrmMedia(currentMedia)) {
seekStrmDirectTo(resumeAt)
return
}
try {
video.currentTime = resumeAt
} catch {
@@ -1243,6 +1312,7 @@ export function PlayerPage() {
}, [
activeBurnedSubtitleStream,
clearFallbackTimer,
cloudReloadNonce,
cloudWaiting,
hlsSource,
hlsUnavailable,
@@ -1250,6 +1320,8 @@ export function PlayerPage() {
mediaId,
mode,
playbackProvider,
reloadCloudHls,
seekStrmDirectTo,
selectedQuality,
setPlaybackMode,
switchToLocalHLS,
@@ -1398,8 +1470,9 @@ export function PlayerPage() {
const modeRef = useRef(mode)
useEffect(() => {
hlsStartSecRef.current = hlsStartSec
hlsSourceRef.current = hlsSource
modeRef.current = mode
}, [hlsStartSec, mode])
}, [hlsStartSec, hlsSource, mode])
// Persist resume position every 10 seconds while playing, and immediately upon
// pause/page hide/unmount. Bind after mediaId is available because
@@ -1412,16 +1485,21 @@ export function PlayerPage() {
lastSentRef.current = 0
}
// 本地 HLS 的播放列表 t=0 对应 hlsStartSec,云 HLS 的播放列表本身就是整条
// 时间轴(起点恒为 0)。以前两种模式都叠加 hlsStartSec:只要本地转码残留过
// 一个非 0 起点再切到云端,上报的进度和总时长就会被整体抬高(实测把 7624s
// 的片子记成 8346s 并直接标成「已看完」)。
const timelineOffsetSec = () =>
modeRef.current === 'hls' && hlsSourceRef.current === 'local' ? hlsStartSecRef.current : 0
const absolutePositionMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = currentStartSec + (video.currentTime || 0)
const absolute = timelineOffsetSec() + (video.currentTime || 0)
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
}
const absoluteDurationMs = () => {
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const mediaDur = mediaRef.current?.duration_sec || 0
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
const totalSec = Math.max(mediaDur, timelineOffsetSec() + streamDur)
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
}
const send = (keepalive: boolean) => {
@@ -1782,16 +1860,27 @@ export function PlayerPage() {
toast('该媒体为直连播放,无需且不支持转码')
return
}
const video = ref.current
// HLS 卸载会把 video.currentTime 归零,所以先按当前模式换算成绝对秒数记下来。
const position = video
? mode === 'hls' && hlsSource === 'local'
? hlsStartSec + video.currentTime
: video.currentTime
: 0
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
enterHlsPlayback(ref.current?.currentTime || 0)
enterHlsPlayback(position)
} else {
setCloudWaiting(false)
// 直连要等 loadedmetadata 再跳(见播放方式接线 effect),这里先把目标位置存下来。
pendingDirectStartRef.current = position
}
setPlaybackMode(next)
}, [
enterHlsPlayback,
hlsSource,
hlsStartSec,
isDirectStream,
mode,
setPlaybackMode,
@@ -1989,16 +2078,22 @@ export function PlayerPage() {
const selectPlaybackQuality = useCallback(
(quality: PlaybackQuality) => {
const video = ref.current
const position = video?.currentTime || 0
const localTime = video?.currentTime || 0
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
if (quality.source === 'original') {
setCloudWaiting(false)
setHlsSource('local')
// 切回原始文件直连:位置由直连分支的 loadedmetadata 跳转负责带过去。
pendingDirectStartRef.current = position
setPlaybackMode('direct')
return
}
if (quality.source === 'cloud') {
setSelectedQuality(quality.id)
setHlsSource('cloud')
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
cloudReloadsRef.current = []
if (quality.available) {
setCloudWaiting(false)
setCloudWaitMessage('')
@@ -2025,7 +2120,7 @@ export function PlayerPage() {
if (position > 2) setHlsStartSec(position)
setPlaybackMode('hls')
},
[setPlaybackMode, startCloudTranscode],
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
)
const changeSubtitleChineseMode = useCallback(