Compare commits

...

8 Commits

Author SHA1 Message Date
truewhile db516ded61 优化 2026-10-29 00:09:00 +08:00
truewhile 416d4c8c3b 优化 2026-10-27 22:14:40 +08:00
truewhile 78cfe0aba2 优化 2026-10-24 13:51:25 +08:00
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
44 changed files with 3546 additions and 101 deletions
+8
View File
@@ -113,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+6
View File
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
+4 -2
View File
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+2 -2
View File
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
+72 -1
View File
@@ -1,13 +1,18 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
// GET /playback/:id/segments
// PUT /playback/:id/segments {kind, position_ms, clear}
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
//
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
// 外部数据永远由提供方刷新,不接受客户端写入。
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
}
}
// playbackSegmentsMarkRequest 是手动标记的入参。
type playbackSegmentsMarkRequest struct {
// Kind 只接受 intro / credits。
Kind string `json:"kind" binding:"required"`
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
PositionMs int64 `json:"position_ms"`
// Clear 为 true 时删除该类型的标记。
Clear bool `json:"clear"`
}
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
//
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
// 最高),避免自己再发一次 GET 造成竞态。
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc.Segments == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
return
}
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
if service.IsEmbyRemoteID(m.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
return
}
var req playbackSegmentsMarkRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
Kind: req.Kind,
PositionMs: req.PositionMs,
Clear: req.Clear,
})
if errors.Is(err, service.ErrInvalidManualMark) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
return
}
if err != nil {
if svc.Log != nil {
svc.Log.Warn("save manual skip mark failed",
zap.String("media_id", m.ID), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
return
}
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil {
if svc.Log != nil {
svc.Log.Debug("list media segments after mark failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
}
c.JSON(http.StatusOK, gin.H{
"segments": service.ToSegmentViews(rows),
"auto_skip": resolveAutoSkipFlag(c, svc),
})
}
}
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
// currently in effect. It reuses selectedPlayProfile so the server agrees with
// the UI about which profile is active (explicit header first, then the user's
+445
View File
@@ -0,0 +1,445 @@
package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 媒体代理在「上游签名票被拒」时的行为契约。
//
// 背景(真实故障):聆阅·懒人听书源的音频直链是第三方中转现签的腾讯 CDN 票,
// 票被 CDN 判掉时上游回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的还是同一个 URL,于是 App 日志里连续 4~8 个
// ERROR_CODE_IO_BAD_HTTP_STATUS (2004),表现为「这本书怎么都播不了」。
//
// 现在的契约:
// - 能换票 → 用新票重拉,直接返回媒体本体(第一个请求就被治好);
// - 换不到 → 502 + 明确文案,而不是把 401 丢给播放器;
// - 非音轨地址(漫画图片防盗链等)→ 维持原样透传,不改变既有行为。
func TestReaderMediaPayloadRejected(t *testing.T) {
cases := []struct {
name string
head string
contentType string
want bool
}{
{"HTML 错误页", "<!DOCTYPE html><html><body>403</body></html>", "text/html; charset=utf-8", true},
{"JSON 报错", `{"code":403,"message":"expired"}`, "application/json", true},
{"数组形态报错", `[{"error":"bad ticket"}]`, "application/json; charset=utf-8", true},
{"纯文本报错", "ticket expired", "text/plain", false},
{"正常 m4a", "\x00\x00\x00\x20ftypmp42", "audio/mp4", false},
{"正常 mp3", "ID3\x04\x00", "audio/mpeg", false},
{"正常图片", "\xff\xd8\xff\xe0", "image/jpeg", false},
{"播放列表", "#EXTM3U\n#EXTINF:10,", "application/vnd.apple.mpegurl", false},
{"空体", "", "text/html", false},
{"HTML 类型但二进制体", "\x00\x00\x00\x20ftypmp42", "text/html", false},
}
for _, c := range cases {
got := readerMediaPayloadRejected([]byte(c.head), c.contentType)
if got != c.want {
t.Errorf("%s: readerMediaPayloadRejected(%q, %q) = %v,期望 %v",
c.name, c.head, c.contentType, got, c.want)
}
}
}
// TestReaderMediaProxyHealsStaleTicket 票被上游拒绝时代理自己换票重拉,
// 第一个请求就返回媒体本体(不必等播放器重试)。
func TestReaderMediaProxyHealsStaleTicket(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
// 让中转再签一张新票:章节缓存里那张就此变成「旧票」。
srv.rotate()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("换票重试后 status=%d body=%s", code, body)
}
if !strings.Contains(body, "fake-audio-bytes") {
t.Fatalf("响应体不是媒体: %q", body)
}
if got := srv.contentHits(); got < 2 {
t.Fatalf("应当回源重取章节,实际请求 %d 次", got)
}
}
// TestReaderMediaProxyFailsClearlyWhenRefreshImpossible 换不到票时给 502 + 明确文案,
// 而不是把 401 透传给播放器(那样播放器只会对着必然失败的地址反复重试)。
func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.rotate()
srv.stopIssuing()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("换票失败应当返回 502,实际 %d body=%s", code, body)
}
if !strings.Contains(body, "签名失效") {
t.Fatalf("错误文案应说明是音源签名失效: %s", body)
}
}
// TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址,
// 上游 4xx 仍按原状态透传(既有行为不变)。
func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
// 书源域名下的图片地址:不在任何音轨缓存里。
imgURL := srv.URL + "/img/forbidden.jpg"
signed := svc.Reader.ProxyURL(book.ID, imgURL)
code, body := getReaderMedia(t, router, signed)
if code == http.StatusBadGateway || code == http.StatusUnauthorized {
t.Fatalf("非音轨地址不该走换票分支,实际 %d body=%s", code, body)
}
if code != http.StatusForbidden {
t.Fatalf("图片 403 应原样透传,实际 %d body=%s", code, body)
}
if got := srv.contentHits(); got != 0 {
t.Fatalf("图片失败不该触发回源取章节,实际 %d 次", got)
}
}
// TestReaderMediaProxyRejectsNonMediaPayload 上游用 200 回一段 HTML/JSON 错误页时,
// 代理直接判失败,不把网页喂给播放器(否则报「格式不支持」)。
func TestReaderMediaProxyRejectsNonMediaPayload(t *testing.T) {
svc, srv, book := prepareMediaTicketBook(t)
router := mediaRouterForTest(svc)
signed := primeTicketBook(t, svc, book.ID)
srv.serveErrorPage()
code, body := getReaderMedia(t, router, signed)
if code != http.StatusBadGateway {
t.Fatalf("非媒体内容应当返回 502,实际 %d body=%.160s", code, body)
}
if !strings.Contains(body, "非媒体内容") {
t.Fatalf("错误文案应说明上游返回了非媒体内容: %s", body)
}
}
// ─── 上游抖动:连接被 reset 时的重试与截断可见化 ─────────────────────────
// TestReaderMediaProbeRetriesConnectionReset 上游在响应体中途 RST 连接时,
// 代理要退避重试,而不是第一次读失败就回 502。
//
// 真实故障:腾讯 CDN 的跨境边缘在并发/长传输时会直接 RST,同一章重试一次
// 往往就好。以前这里读 body 失败即判死,把一次可恢复的抖动变成「这本书播不了」。
func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
var attempts int32
const bodyBytes = 200 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&attempts, 1)
if n == 1 {
// 第一次:声明长度,写一小段后直接断开连接(模拟 RST/提前 EOF)。
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42"))
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
return
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
payload := make([]byte, 8<<10)
for written := 0; written < bodyBytes; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
code, body := getReaderMedia(t, router, signed)
if code != http.StatusOK {
t.Fatalf("连接被 reset 后应当重试成功,实际 status=%d body=%.200s", code, body)
}
if int32(len(body)) != bodyBytes {
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
}
if got := atomic.LoadInt32(&attempts); got < 2 {
t.Fatalf("应当重试过,实际上游请求 %d 次", got)
}
}
// TestReaderMediaAbortsWhenUpstreamTruncates 上游中途断开且重试都失败时,
// 代理必须让客户端看到「传输不完整」,不能返回一个长度正确却内容缺失的响应。
//
// 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的
// 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。
func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
const declared = 400 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(declared))
// 先写够探测用的前缀,再断开:探测能过,透传阶段才断。
payload := make([]byte, 8<<10)
for written := 0; written < 128<<10; written += len(payload) {
if _, err := w.Write(payload); err != nil {
return
}
}
w.(http.Flusher).Flush()
if hj, ok := w.(http.Hijacker); ok {
if conn, _, err := hj.Hijack(); err == nil {
_ = conn.Close()
}
}
}))
defer srv.Close()
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
// panic(http.ErrAbortHandler) 由 net/http 捕获;httptest 下会向上冒泡,
// 这里断言它确实发生了(即「没有静默地把半截文件当成功」)。
defer func() {
recovered := recover()
if recovered == nil {
t.Fatalf("上游截断时必须中止响应(panic(http.ErrAbortHandler)),实际正常返回了 %d 字节",
w.Body.Len())
}
if recovered != http.ErrAbortHandler {
t.Fatalf("中止响应时应使用 http.ErrAbortHandler,实际 %v", recovered)
}
}()
router.ServeHTTP(w, req)
if w.Body.Len() >= declared {
t.Fatalf("不应交付完整长度的响应体(上游只写了 128KB)")
}
}
func mediaRouterForTest(svc *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
// /api/reader/media 挂在公共 api 组(签名鉴权,不需要 JWT),单独注册即可。
r.GET("/api/reader/media", readerMediaProxyHandler(svc))
return r
}
func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
t.Helper()
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0)
if err != nil {
t.Fatalf("首次取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return out.Tracks[0]
}
// ─── 模拟「中转现签 CDN 直链」的上游 ───────────────────────────────────────
// mediaTicketServer:
// - /book/1 详情页
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的票;旧票回**空的 401**(与腾讯 CDN 一致)
// - /img/* 图片资源,固定 403(模拟防盗链)
type mediaTicketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
noToken bool
errorPage bool
}
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
t.Helper()
s := &mediaTicketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := s.newTokenLocked(fmt.Sprintf("ticket-%d", n))
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
if s.errorPageNow() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
return
}
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
// 与上游一致:空 body、无 Content-Type。
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
case strings.HasPrefix(r.URL.Path, "/img/"):
w.WriteHeader(http.StatusForbidden)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(s.Close)
return s
}
// newTokenLocked 签一张新票(调用方需持有或随后获取锁;这里内部加锁)。
func (s *mediaTicketServer) newTokenLocked(token string) string {
s.mu.Lock()
defer s.mu.Unlock()
s.issued = append(s.issued, token)
return token
}
// rotate 模拟「中转又签了一张新票」:章节缓存里那张就此失效。
func (s *mediaTicketServer) rotate() {
s.mu.Lock()
s.issued = append(s.issued, fmt.Sprintf("rotated-%d", len(s.issued)))
s.mu.Unlock()
}
// stopIssuing 让书源此后给不出播放地址(模拟中转本身挂了)。
func (s *mediaTicketServer) stopIssuing() {
s.mu.Lock()
s.noToken = true
s.mu.Unlock()
}
// serveErrorPage 让媒体端点改用 200 + HTML 错误页(直链失效的另一种形态)。
func (s *mediaTicketServer) serveErrorPage() {
s.mu.Lock()
s.errorPage = true
s.mu.Unlock()
}
func (s *mediaTicketServer) errorPageNow() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.errorPage
}
func (s *mediaTicketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
// prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。
func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
// 占位 ticket server:只用来满足清理钩子,不参与请求。
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "上游直连测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, upstream)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: upstream,
OriginName: "上游直连测试",
OriginType: 1,
BookURL: upstream + "/book/1",
}, "上游直连之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
return svc, srv, book
}
func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServer, *model.ReaderBook) {
t.Helper()
svc := newReaderHandlerContainer(t)
// 章节缓存必须落在磁盘上:换票要凭落盘载荷找回「这个地址属于哪一章」。
// newReaderHandlerContainer 只给空 cfg,这里补一个临时缓存目录。
svc.Cfg.Cache.CacheDir = t.TempDir()
srv := newMediaTicketServer(t)
srcJSON := fmt.Sprintf(`{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": %q,
"ruleContent": {"content": "$.data.url"}
}`, srv.URL)
if _, err := svc.Reader.ImportSources(t.Context(), "u1", srcJSON); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.Reader.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("读不到书源: %v", err)
}
book, err := svc.Reader.AddBook(t.Context(), "u1", reader.SearchOrigin{
SourceID: srcs[0].ID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
+240 -11
View File
@@ -2,11 +2,16 @@
package handler
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -869,12 +874,18 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
// 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页);
// 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。
probeBody := c.Request.Method == http.MethodGet
media, err := fetchReaderMedia(c.Request.Context(), svc, book, rawURL, c.GetHeader("Range"), mediaOptions.Headers, probeBody)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
resp := media.resp
defer resp.Body.Close()
// 换过票时用新地址:m3u8 里的相对分片要按新地址解析。
rawURL = media.url
ct := resp.Header.Get("Content-Type")
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
@@ -885,6 +896,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
sniffed = append(append([]byte(nil), media.head...), sniffed...)
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
@@ -897,20 +909,21 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
// 200 的 GET:media.head 已经读过一小段,先用它判播放列表与错误页。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中),
// 所以以 #EXTM3U 标记为准。
if probeBody && resp.StatusCode == http.StatusOK {
head := media.head
if reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 走到这里说明 head 不是错误页(fetchReaderMedia 已经把错误页换票重试过了),
// 原样拼回去继续流式透传。Content-Length 因为已读过一部分会失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
@@ -921,7 +934,7 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -931,13 +944,229 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
streamReaderMediaBody(c.Writer, resp.Body)
}
}
// streamReaderMediaBody 把上游响应体写到客户端,并在上游中途断开时**主动掐断**连接。
//
// 这是修复「播放器拿到半首歌」的关键:以前这里 `_, _ = io.Copy(...)` 把错误直接
// 丢掉,上游(腾讯 CDN 边缘)在跨境链路上 RST 连接时,客户端收到的是一个正常
// 结束、但字节数不足的响应 —— 播放器无从判断,只能报
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(看起来像「格式不支持」,实际是被截断)。
//
// 现在改成:上游没读完就中止这个响应,客户端因此拿到「传输不完整」而不是「正常结束」,
// 播放器可以按常规的重试/重连逻辑处理,而不是误判成格式问题。
func streamReaderMediaBody(w io.Writer, body io.Reader) {
if _, err := io.Copy(w, body); err != nil {
// panic(http.ErrAbortHandler) 是 net/http 约定的「异常中止」:不再补写
// chunked 结尾,直接关闭连接,客户端据此判定响应不完整。
panic(http.ErrAbortHandler)
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// readerMediaProbeBytes 媒体代理嗅探上游响应体用的前缀长度。
//
// 这个前缀同时用于三件事:判定 HLS 播放列表(#EXTM3U)、判定「其实是错误页」、
// 以及把已读出的字节原样拼回响应体继续流式透传。
const readerMediaProbeBytes = 64 << 10
// readerMediaResult 一次媒体拉取的结果。
type readerMediaResult struct {
resp *http.Response
// url 是本次真正请求的媒体地址(换过票时是新票),m3u8 重写要用它做 base。
url string
// head 是已从响应体读出的前缀(仅 200 的 GET 才有),调用方写响应时要先写它。
head []byte
// rejected 表示 200 的响应体其实是错误页(HTML/JSON 报错),不能喂给播放器。
rejected bool
}
// fetchReaderMedia 拉取媒体,并在「签名票失效」时换票重试一次。
//
// 听书源的播放地址是第三方中转现签的 CDN 直链(vkey/uid 带时效),票被上游判掉时
// 有两种表现,这里都要兜住:
//
// ① 空的 401:没有 Content-Type、没有 body(腾讯 CDN 对无效 vkey/uid 的固定回答);
// ② 200 + HTML/JSON 错误页:中转自己出错时把它的报错页当成媒体回给我们。
//
// 旧行为是两种都原样透传给播放器:① 变成连续多个 401(播放器重试的是同一个
// 必然失败的地址),② 变成 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED(19 个
// extractor 一个都不认)。两者的根因都是「这张票已经不能用了」,所以统一处理成
// 回源换一张新票再拉:
//
// - 换票成功 → 用新地址重拉,新票同时写回章节缓存,后续请求自然有效;
// - 地址不属于可换票的音轨(漫画图片防盗链的 403 等)→ 原样透传上游状态,
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
func fetchReaderMedia(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, error) {
// 上游 CDN 在并发/跨境链路上偶发 reset 连接(实测同一章重试即可成功),
// 探测阶段读到一半断开不能直接判死,先按退避重试几次。
out, refreshable, err := fetchReaderMediaWithRetry(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err != nil || !refreshable {
return out, err
}
fresh, canRefresh, refreshErr := svc.Reader.RefreshMediaURL(ctx, book, rawURL)
if !canRefresh {
// 换不动(漫画图片防盗链的 403 等):原样透传上游状态,保持既有行为。
// 但有两类失败例外——它们透传给播放器毫无意义(播放器只会一直重试
// 同一个必然失败的地址,或者把网页当成音频),改成明确错误让前端提示。
switch {
case out.rejected:
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源返回了非媒体内容(%s),请稍后重试或换源",
strings.TrimSpace(out.resp.Header.Get("Content-Type")))
case out.resp.StatusCode == http.StatusUnauthorized:
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源鉴权被拒(上游 401),请稍后重试或换源")
}
return out, nil
}
// 换得动就必须先释放这张失败响应的连接,再发新请求。
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
if refreshErr != nil {
return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr)
}
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
if retryErr != nil {
return nil, retryErr
}
if retried.rejected {
_ = retried.resp.Body.Close()
return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode)
}
return retried, nil
}
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
//
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
// 重试一次往往就好。以前这里读 body 失败就直接回 502,把一次可恢复的抖动
// 变成了「这本书播不了」。
const readerMediaProbeAttempts = 3
// readerMediaProbeRetryDelay 探测重试的退避基数。
const readerMediaProbeRetryDelay = 300 * time.Millisecond
// readerMediaProbeError 探测阶段的可重试错误(连接被 reset / 提前 EOF)。
type readerMediaProbeError struct{ err error }
func (e *readerMediaProbeError) Error() string { return e.err.Error() }
func (e *readerMediaProbeError) Unwrap() error { return e.err }
// fetchReaderMediaWithRetry 探测阶段按退避重试,只对「连接中断」类错误重试:
// 上游明确回了 4xx 时重试同一张票没有意义(那是换票流程的事)。
func fetchReaderMediaWithRetry(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
var lastErr error
for attempt := 1; attempt <= readerMediaProbeAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return nil, false, ctx.Err()
case <-time.After(readerMediaProbeRetryDelay * time.Duration(attempt-1)):
}
}
out, refreshable, err := fetchReaderMediaOnce(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
if err == nil {
return out, refreshable, nil
}
var probeErr *readerMediaProbeError
if !errors.As(err, &probeErr) {
return nil, false, err
}
lastErr = err
}
return nil, false, lastErr
}
// fetchReaderMediaOnce 发一次媒体请求并做可用性判定。
//
// 返回的 refreshable 表示「这次失败值得换一张签名票再试」:
// 上游 401/403/410,或 200 但响应体其实是错误页(详见 fetchReaderMedia 的说明)。
// rejected 表示「200 但内容不是媒体」——调用方据此在无法换票时报明确错误,
// 而不是把网页喂给播放器。
func fetchReaderMediaOnce(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
rawURL, rangeHeader string,
options map[string]string,
probeBody bool,
) (*readerMediaResult, bool, error) {
resp, err := svc.Reader.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, options)
if err != nil {
return nil, false, err
}
if reader.MediaNeedsRefresh(resp.StatusCode) {
return &readerMediaResult{resp: resp, url: rawURL}, true, nil
}
if !probeBody || resp.StatusCode != http.StatusOK {
return &readerMediaResult{resp: resp, url: rawURL}, false, nil
}
// 先读一小段判断是不是错误页/播放列表;不是就原样拼回去继续流式透传。
head, readErr := io.ReadAll(io.LimitReader(resp.Body, readerMediaProbeBytes))
if readErr != nil {
_ = resp.Body.Close()
// 连接被 reset / 提前 EOF 属于上游抖动,交给上层退避重试。
return nil, false, &readerMediaProbeError{err: fmt.Errorf("读取媒体响应失败: %w", readErr)}
}
rejected := readerMediaPayloadRejected(head, resp.Header.Get("Content-Type"))
return &readerMediaResult{resp: resp, url: rawURL, head: head, rejected: rejected}, rejected, nil
}
// readerMediaPayloadRejected 判断上游 200 响应体其实是错误页而不是媒体。
//
// 直链失效的另一个变种:CDN/中转不返回 4xx,而是回一段 HTML 错误页或 JSON
// 报错(Content-Type 是 text/html、application/json)。这类字节喂给播放器,
// ExoPlayer 的 19 个 extractor 全都不认,报出来的是
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED —— 看起来像「格式不支持」,
// 实际是「拿到了一张网页」。这里按 Content-Type + 首字符识别出来,直接判失败。
func readerMediaPayloadRejected(head []byte, contentType string) bool {
if len(head) == 0 {
return false
}
lower := strings.ToLower(contentType)
errish := strings.Contains(lower, "html") ||
strings.Contains(lower, "json") ||
strings.HasPrefix(lower, "text/")
if !errish {
return false
}
trimmed := bytes.TrimSpace(head)
if len(trimmed) == 0 {
return false
}
switch trimmed[0] {
case '<', '{', '[':
return true
default:
return false
}
}
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
@@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
+14
View File
@@ -27,6 +27,10 @@ import (
// historyListHandler returns the caller's history rows joined with the
// matching media in a single response.
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
@@ -34,6 +38,12 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
if limit <= 0 || limit > 500 {
limit = 50
}
includeMedia := true
if raw := strings.TrimSpace(c.Query("include_media")); raw != "" {
if parsed, err := strconv.ParseBool(raw); err == nil {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -42,7 +52,11 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+72
View File
@@ -103,3 +103,75 @@ func TestHistoryContinueRemovesMissingMedia(t *testing.T) {
t.Fatalf("stale history rows remaining = %d, want 0", staleCount)
}
}
// include_media=0 只是不把 media 写进响应,行数和可见性判断都不变。
func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
media := model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/one.mkv",
}
if err := db.Create(&media).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-1"},
UserID: "user-1",
MediaID: media.ID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: time.Now(),
}).Error; err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
type listRow struct {
MediaID string `json:"media_id"`
Media *model.Media `json:"media"`
}
full := httptest.NewRecorder()
router.ServeHTTP(full, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10", nil))
var fullRows []listRow
if err := json.Unmarshal(full.Body.Bytes(), &fullRows); err != nil {
t.Fatal(err)
}
if len(fullRows) != 1 || fullRows[0].Media == nil || fullRows[0].Media.ID != media.ID {
t.Fatalf("default response should embed media: %s", full.Body.String())
}
lean := httptest.NewRecorder()
router.ServeHTTP(lean, httptest.NewRequest(http.MethodGet, "/watch-history?limit=10&include_media=0", nil))
var leanRows []listRow
if err := json.Unmarshal(lean.Body.Bytes(), &leanRows); err != nil {
t.Fatal(err)
}
if len(leanRows) != 1 || leanRows[0].MediaID != media.ID {
t.Fatalf("lean response should keep the row: %s", lean.Body.String())
}
if leanRows[0].Media != nil {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
+43 -4
View File
@@ -7,6 +7,7 @@ import (
"compress/flate"
"compress/gzip"
"compress/zlib"
"crypto/tls"
"io"
"net/http"
"net/url"
@@ -88,10 +89,51 @@ func StripAcceptEncoding(h http.Header) {
//
// When useProxy is false, the client is created without proxy plumbing so
// the request goes out direct, matching the user's checkbox intent.
//
// 注意 http.Client.Timeout 是「整个请求」的上限,包含读完响应体。书源的
// 搜索/目录/正文都是小响应,30 秒足够;但同一个 client 也被用来流转发
// 音频/视频(见 NewMediaHTTPClient),那里必须换一个没有整体超时的 client。
func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
if timeoutSeconds <= 0 {
timeoutSeconds = 15
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: newSiteTransport(useProxy),
}
}
// NewMediaHTTPClient 拉取并转发媒体流(音频/视频/大图)的客户端。
//
// 为什么不能用 NewSiteHTTPClient:http.Client.Timeout 会覆盖「读完 body」这个
// 阶段。一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,一旦超时,net/http 会
// 直接掐断响应体,而调用方的 io.Copy 只是正常返回 —— 表现为**静默截断**
// (播放器拿到半首歌,或判「格式不支持」),或者上游慢到 Do 本身报错 → 502。
//
// 为什么强制 HTTP/1.1:实测腾讯 CDN(vb.stream.tencentmusic.com)的 h2 边缘在
// 长连接大文件上不按预期回 WINDOW_UPDATE,Go 的 http2 Transport 初始连接窗口
// 是 1MiB,于是下载**稳定卡在 1MiB/2MiB/3MiB 整数倍**处断开;同一 URL 用
// HTTP/1.1(wget、curl --http1.1)从头到尾都完整。因此媒体通路固定走 h1.1:
// 音视频本来就没有「多路复用省请求」的收益,反而是流控 bug 的受害者。
func NewMediaHTTPClient(useProxy bool) *http.Client {
tr := newSiteTransport(useProxy)
// 等响应头最多 90 秒:跨境 CDN 首包偶发要几十秒,但绝不能无上限挂着。
tr.ResponseHeaderTimeout = 90 * time.Second
// 媒体是长连接大流量,连接池开大一些,避免并发取图/拖动进度时互相排队。
tr.MaxIdleConns = 32
tr.MaxIdleConnsPerHost = 16
// 关闭 HTTP/2:见上面的流控说明。
tr.ForceAttemptHTTP2 = false
tr.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{}
// 不复用连接:实测腾讯 CDN 的边缘在「同一条 keep-alive 连接上连续取多个
// 大对象」时会直接 RST 第二个请求的响应体(容器内 wget/curl 每次新建连接
// 就完全正常)。音视频请求本来就稀疏,为省一次 TLS 握手去冒截断风险不划算。
tr.DisableKeepAlives = true
return &http.Client{Transport: tr}
}
// newSiteTransport 书源请求共用的 Transport(媒体与规则请求配置一致的部分)。
func newSiteTransport(useProxy bool) *http.Transport {
tr := &http.Transport{
MaxIdleConns: 16,
MaxIdleConnsPerHost: 4,
@@ -104,10 +146,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
return ProxyFromEnvironmentOrSystem(r)
}
}
return &http.Client{
Timeout: time.Duration(timeoutSeconds) * time.Second,
Transport: tr,
}
return tr
}
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
+12
View File
@@ -17,12 +17,24 @@ var gzipExcludedExtensions = []string{
// apiGzipExcludedPrefixes 大文件流式传输(Range 语义)与 WS/SSE 长连接
// 不参与 gzip:压缩会破坏 Range / 逐块推送语义。
//
// 阅读媒体代理(/api/reader/media、/api/reader/local/audio、
// /api/reader/audio/transcode)同样必须排除,原因有两条:
// - 它们透传上游的 206 Partial Content。gin-contrib/gzip 在压缩时会删掉
// Content-Length,却把 Content-Range 原样留下,客户端看到的是「声明
// N 字节、实际收到 M 字节的 gzip 流」,自己发 Accept-Encoding 的播放器
// (ExoPlayer 不透明处理)直接判 ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED。
// - 音视频本来就是压缩格式,再 gzip 一遍只烧 CPU(3.5MB 的 m4a 压完还是
// 3.5MB 量级),对带宽没有收益。
var apiGzipExcludedPrefixes = []string{
"/api/stream/",
"/api/hls/",
"/api/img",
"/api/subtitles/",
"/api/strm/play/",
"/api/reader/media",
"/api/reader/local/audio",
"/api/reader/audio/transcode",
"/api/ws",
"/api/events",
}
+46
View File
@@ -70,6 +70,52 @@ func TestGzipAPIExcludesStreamPath(t *testing.T) {
}
}
// TestGzipAPIExcludesReaderMedia 阅读媒体代理必须排除在 gzip 之外。
//
// 它会透传 CDN 的 206 Partial Content。gin-contrib/gzip 压缩时会删掉
// Content-Length 却保留 Content-Range,客户端看到「声明 N 字节、实收 M 字节」,
// 自己发 Accept-Encoding 的播放器(ExoPlayer)直接判
// ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,表现为「音频格式不支持」。
func TestGzipAPIExcludesReaderMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
api := router.Group("/api")
api.Use(GzipAPI())
// 用 206 + Content-Range 模拟真实的音频分段响应。
segment := func(c *gin.Context) {
c.Header("Content-Range", "bytes 0-99/100")
c.Data(http.StatusPartialContent, "audio/mp4", []byte(strings.Repeat("x", 4096)))
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
api.GET(path, segment)
}
for _, path := range []string{
"/reader/media",
"/reader/local/audio",
"/reader/audio/transcode",
} {
req := httptest.NewRequest(http.MethodGet, "/api"+path+"?b=book&u=x&s=y", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if got := w.Header().Get("Content-Encoding"); got == "gzip" {
t.Errorf("%s 不该被 gzip(Range 语义会被破坏)", path)
}
if got := w.Header().Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("%s 的 Content-Range 被破坏: %q", path, got)
}
if w.Body.Len() != 4096 {
t.Errorf("%s 响应体长度 = %d,期望 4096", path, w.Body.Len())
}
}
}
func TestGzipStaticCompressesAssets(t *testing.T) {
router := newGzipTestRouter()
+8 -2
View File
@@ -237,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" || m.TMDbID <= 0 {
if libraryID == "" || title == "" {
return nil, nil
}
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
// 功能在未刮削的库上完全不生效。
query = query.Where("library_id = ? AND title = ?", libraryID, title)
if m.TMDbID > 0 {
query = query.Where("tm_db_id = ?", m.TMDbID)
}
}
var rows []model.Media
if err := query.Find(&rows).Error; err != nil {
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
})
}
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
// triple in a single transaction. Used by manual marking, where the user sets
// one boundary at a time and must not disturb the other kinds.
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
Delete(&model.MediaSegment{}).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
}
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
var row model.MediaSegmentFetch
+17 -1
View File
@@ -345,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
}
}
// 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。
// 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查
// (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
episodic := true
if p.ParentID != "" {
if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil {
episodic = value
}
}
if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回
// mediaItems,由它按 Movie 过滤,而不是整行判空。
if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") {
return emptyItemsEnvelope(p.StartIndex), nil
}
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
@@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int,
}
return cleaned, nil
}
// segmentRangeCleanupSettingKey marks the one-time removal of impossible
// skip ranges written by the older end_ms: 0 interpretation.
const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done"
// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。
//
// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是
// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集;
// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮,
// 没有任何保留价值,直接硬删。
//
// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。
func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) {
if c == nil || c.Repo == nil || c.Repo.DB == nil {
return 0, nil
}
if c.Repo.Setting != nil {
if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
return 0, nil
}
}
res := c.Repo.DB.WithContext(ctx).Unscoped().
Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}).
Where("end_ms <= start_ms").
Delete(&model.MediaSegment{})
if res.Error != nil {
return 0, res.Error
}
cleaned := int(res.RowsAffected)
if c.Repo.Setting != nil {
if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil {
c.Log.Warn("mark segment range cleanup done failed", zap.Error(err))
}
}
if cleaned > 0 && c.Log != nil {
c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
return cleaned, nil
}
+31 -17
View File
@@ -41,15 +41,6 @@ const (
introDBMaxRetryDelay = 3 * time.Second
)
// IntroDBSpan is one resolved skip range, still in provider terms.
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
// end_ms: null for end credits); the caller resolves it against the duration.
type IntroDBSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
log *zap.Logger
@@ -113,7 +104,7 @@ type introDBResponse struct {
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]SegmentSpan, error) {
if s == nil || s.client == nil {
return nil, errors.New("introdb service nil")
}
@@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
}
}
// Source implements SegmentProvider.
func (s *IntroDBService) Source() string { return IntroDBSource }
// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。
func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if q.IsMovie {
return s.Fetch(ctx, q.TMDbID, 0, 0)
}
return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode)
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []IntroDBSpan
spans []SegmentSpan
err error
retryable bool
retryAfter time.Duration
@@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
// intro -> recap -> credits -> preview order so the player sees the earliest
// range first.
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
func parseIntroDBResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
@@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
{"credits", raw.Credits},
{"preview", raw.Preview},
}
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits))
for _, group := range groups {
for _, r := range group.ranges {
var start int64
@@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
if start < 0 {
start = 0
}
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
if end != 0 && end <= start {
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种
// 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零:
// end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法,
// 落成 0 作为哨兵值由客户端结合片长补齐;
// end_ms: 0 —— 「no segment」,即这一段不存在。
// 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}]
// 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮,
// 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。
if r.EndMs != nil && end <= start {
continue
}
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
// 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾
// 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头,
// 所以宁可不显示也不猜。
if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
}
return spans, nil
+160
View File
@@ -0,0 +1,160 @@
// Package service — IntroDB (https://introdb.app) segment client.
//
// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体,
// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象
// (而不是按类型分数组),片尾字段名是 outro 而不是 credits,
// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。
const IntroDBAppBaseURL = "https://api.introdb.app"
// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。
const IntroDBAppSource = "introdb"
const (
introDBAppTimeout = 8 * time.Second
introDBAppMaxBodySize = 1 << 20
)
// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。
type introDBAppSegment struct {
StartMs *int64 `json:"start_ms"`
EndMs *int64 `json:"end_ms"`
}
// introDBAppResponse 对应 GET /segments。
type introDBAppResponse struct {
IMDbID string `json:"imdb_id"`
MediaType string `json:"media_type"`
IsMovie bool `json:"is_movie"`
Season int `json:"season"`
Episode int `json:"episode"`
Intro *introDBAppSegment `json:"intro"`
Recap *introDBAppSegment `json:"recap"`
Outro *introDBAppSegment `json:"outro"`
PostCredits *introDBAppSegment `json:"post_credits"`
}
// IntroDBAppService 查询 IntroDB 的片段数据。
type IntroDBAppService struct {
log *zap.Logger
client *http.Client
baseURL string
}
// NewIntroDBAppService is the constructor. The client honours environment and
// OS proxy settings so it behaves like the other third-party API clients.
func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService {
return &IntroDBAppService{
log: log,
client: NewExternalHTTPClient(introDBAppTimeout),
baseURL: IntroDBAppBaseURL,
}
}
// SetBaseURL overrides the API root (tests, mirrors).
func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
}
return s
}
// Source implements SegmentProvider.
func (s *IntroDBAppService) Source() string { return IntroDBAppSource }
// FetchSegments implements SegmentProvider.
//
// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到
// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。
func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" {
return nil, nil
}
endpoint := s.segmentsURL(q)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
// 404 = 库里没有这部片,是正常的「无数据」,不是错误。
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize))
if err != nil {
return nil, err
}
return parseIntroDBAppResponse(body)
}
func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string {
values := url.Values{}
values.Set("imdb_id", strings.TrimSpace(q.IMDbID))
if q.IsMovie {
values.Set("is_movie", "true")
} else if q.Season > 0 && q.Episode > 0 {
values.Set("season", strconv.Itoa(q.Season))
values.Set("episode", strconv.Itoa(q.Episode))
}
return s.baseURL + "/segments?" + values.Encode()
}
// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。
//
// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的
// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。
func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) {
var raw introDBAppResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
}
groups := []struct {
kind string
seg *introDBAppSegment
}{
{"intro", raw.Intro},
{"recap", raw.Recap},
{"credits", raw.Outro},
{"preview", raw.PostCredits},
}
spans := make([]SegmentSpan, 0, len(groups))
for _, group := range groups {
if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil {
continue
}
start, end := *group.seg.StartMs, *group.seg.EndMs
if start < 0 {
start = 0
}
if end <= start {
continue
}
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
return spans, nil
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) {
t.Helper()
repos := repository.New(newServiceTestDB(t))
svc := NewMediaSegmentService(zap.NewNop(), repos)
m := &model.Media{
Base: model.Base{ID: "ep-1"},
Title: "Show",
Path: "/tv/show/s01e01.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 1,
}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
return svc, repos, m
}
func TestSetManualMarkWritesIntroBoundary(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatalf("set mark: %v", err)
}
rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 {
t.Fatalf("rows = %#v, want 1", rows)
}
if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual {
t.Fatalf("row = %#v, want manual intro", rows[0])
}
if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 {
t.Fatalf("row = %#v, want 0-90000ms", rows[0])
}
}
func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil {
t.Fatal(err)
}
rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits {
t.Fatalf("rows = %#v, want only the credits mark to survive", rows)
}
}
func TestSetManualMarkRejectsInvalidRequests(t *testing.T) {
svc, _, m := newManualMarkFixture(t)
cases := []ManualMarkRequest{
{Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits
{Kind: "preview", PositionMs: 1000}, // 同上
{Kind: model.SegmentKindIntro, PositionMs: 0},
{Kind: model.SegmentKindIntro, PositionMs: -5},
}
for _, req := range cases {
if err := svc.SetManualMark(t.Context(), m, req); err == nil {
t.Fatalf("req %#v: want an error", req)
}
}
}
// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。
func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
sibling := &model.Media{
Base: model.Base{ID: "ep-2"},
Title: "Show",
Path: "/tv/show/s01e02.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 2,
}
other := &model.Media{
Base: model.Base{ID: "ep-3"},
Title: "Show",
Path: "/tv/show/s01e03.mkv",
LibraryID: "lib-1",
SeasonNum: 1,
EpisodeNum: 3,
}
if err := repos.DB.Create(sibling).Error; err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
// other 已经有自己的手动片头标记,不该被覆盖。
if err := svc.SetManualMark(ctx, other, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 45_000,
}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID)
if err != nil {
t.Fatal(err)
}
if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 {
t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows)
}
otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID)
if err != nil {
t.Fatal(err)
}
if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 {
t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows)
}
}
// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。
func TestManualMarkOutranksExternalSource(t *testing.T) {
svc, repos, m := newManualMarkFixture(t)
ctx := t.Context()
if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{
MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource,
}}); err != nil {
t.Fatal(err)
}
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
Kind: model.SegmentKindIntro, PositionMs: 90_000,
}); err != nil {
t.Fatal(err)
}
rows, err := svc.mergeForPlayback(ctx, m.ID)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 {
t.Fatalf("rows = %#v, want the manual mark to win", rows)
}
}
+315 -29
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"errors"
"strings"
"time"
@@ -43,9 +44,20 @@ type SegmentView struct {
}
// ToSegmentViews 转换库内行为对外视图。
//
// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」
// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹
// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
out := make([]SegmentView, 0, len(rows))
for _, row := range rows {
openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview
if row.StartMs < 0 {
continue
}
if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) {
continue
}
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
}
return out
@@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
type MediaSegmentService struct {
log *zap.Logger
repo *repository.Container
introdb *IntroDBService
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
log *zap.Logger
repo *repository.Container
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。
probe *MediaProbeService
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
// prewarmGap 是预热两次请求之间的间隔;测试可设为 0。
prewarmGap time.Duration
now func() time.Time
// providers 是已启用的外部片段源,顺序即注册顺序。
// 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。
providers []SegmentProvider
// imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。
imdb IMDbIDResolver
}
// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。
// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。
type IMDbIDResolver interface {
ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error)
}
// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb,
// 与播放器档案里的「自动跳过」是两件独立的事。
const SegmentSourceSettingKey = "segment.source"
// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。
const SegmentSourceOff = "off"
// SegmentSourceOptions 是设置页可选的源标识。
var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource}
// NewMediaSegmentService is the constructor.
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
return &MediaSegmentService{
@@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ
return s
}
// SetIntroDB wires the provider. Without it the service only reads cached rows.
// SetIntroDB wires the TheIntroDB provider and registers it as a source.
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
if s != nil {
s.introdb = p
s.registerProvider(p)
}
return s
}
// SetIntroDBApp wires the IntroDB (introdb.app) provider.
func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetTODb wires the TODb provider.
func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService {
if s != nil {
s.registerProvider(p)
}
return s
}
// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that
// only address media by IMDb id.
func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService {
if s != nil {
s.imdb = r
}
return s
}
// registerProvider adds a provider once, keeping registration order as the
// fallback order.
func (s *MediaSegmentService) registerProvider(p SegmentProvider) {
if s == nil || p == nil {
return
}
for _, existing := range s.providers {
if existing.Source() == p.Source() {
return
}
}
s.providers = append(s.providers, p)
}
// providerBySource returns the registered provider with that source tag.
func (s *MediaSegmentService) providerBySource(source string) SegmentProvider {
for _, p := range s.providers {
if p.Source() == source {
return p
}
}
return nil
}
// activeProvider 选出本次查询要用的源。
//
// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失;
// 显式配置成 off 才真正停用外部查询。
func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider {
if s == nil || len(s.providers) == 0 {
return nil
}
source := s.configuredSource(ctx)
if source == "" {
return s.providerBySource(IntroDBSource)
}
if source == SegmentSourceOff {
return nil
}
if p := s.providerBySource(source); p != nil {
return p
}
// 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源,
// 让功能继续可用,而不是静默失效。
return s.providerBySource(IntroDBSource)
}
// configuredSource reads the operator's source selection.
func (s *MediaSegmentService) configuredSource(ctx context.Context) string {
if s == nil || s.repo == nil || s.repo.Setting == nil {
return ""
}
v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(v))
}
// SetProbe wires the async media probe (duration backfill for STRM). Without it
// open-ended credits still work once duration is known from elsewhere.
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
@@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
defer s.ensureMediaProbe(ctx, m)
return s.introDBSegments(ctx, m)
return s.communitySegments(ctx, m)
}
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
@@ -147,10 +263,15 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
}
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。
func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
provider := s.activeProvider(ctx)
// 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。
if provider == nil {
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
}
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source())
if err != nil {
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
if ctx.Err() != nil {
@@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
return nil, err
}
if ledger == nil || !ledgerFresh(ledger) {
if _, _, err := s.refresh(ctx, m); err != nil {
if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil {
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
logIntroDBFailure(s.log, 0, err)
}
@@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri
}
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
//
// 顺序体现用户意图的强度:
//
// manual 用户在当前集显式标记
// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库)
// 外部社区库 自动抓取,最容易被覆盖
//
// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个,
// 同级时按区间起点排序展示即可,不必让旧源压住新源。
func segmentSourcePriority(source string) int {
switch source {
case SegmentSourceManual:
return 3
case IntroDBSource:
return 2
case SegmentSourcePropagated:
return 2
case IntroDBSource, IntroDBAppSource, TODbSource:
return 1
default:
return 0
@@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) {
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
}
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。
//
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
if s.introdb == nil {
// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb),
// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) {
if s == nil || provider == nil || m == nil {
return nil, false, nil
}
tmdbID, season, episode := s.queryIDs(ctx, m)
if tmdbID <= 0 {
query, ok := s.segmentQuery(ctx, m, provider)
if !ok {
return nil, false, nil
}
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
@@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
defer cancel()
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
spans, err := provider.FetchSegments(fetchCtx, query)
if err != nil {
return nil, true, err
}
source := provider.Source()
rows := make([]model.MediaSegment, 0, len(spans))
for _, span := range spans {
rows = append(rows, model.MediaSegment{
@@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
Kind: span.Kind,
StartMs: span.StartMs,
EndMs: span.EndMs,
Source: IntroDBSource,
Source: source,
})
}
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil {
return nil, true, err
}
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
MediaID: m.ID,
Source: IntroDBSource,
Source: source,
FetchedAt: time.Now(),
Found: len(rows) > 0,
}); err != nil {
@@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
return rows, true, nil
}
// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
return s.refreshWithProvider(ctx, m, s.activeProvider(ctx))
}
// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID,
// 本次不必发请求。
func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) {
tmdbID, season, episode := s.queryIDs(ctx, m)
isMovie := season <= 0 && episode <= 0
q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie}
switch provider.Source() {
case IntroDBAppSource:
// IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。
if s.imdb == nil || tmdbID <= 0 {
return q, false
}
imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie)
if err != nil || imdbID == "" {
// 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。
// 返回 false 让本次跳过,但不写负缓存,下次还有机会。
s.debug("resolve imdb id failed", m.ID, err)
return q, false
}
q.IMDbID = imdbID
return q, true
default:
return q, tmdbID > 0
}
}
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
@@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
return false
}
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
// ManualMarkRequest 是「标记片头/片尾」的入参。
//
// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点,
// 标记片尾时它是片尾开始点。
type ManualMarkRequest struct {
Kind string
PositionMs int64
// Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。
Clear bool
}
// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。
var ErrInvalidManualMark = errors.New("invalid manual skip mark")
// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。
//
// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后,
// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据
// 会自然回落到优先队列里。
func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error {
if s == nil || s.repo == nil || m == nil || m.ID == "" {
return ErrInvalidManualMark
}
kind := normalizeManualMarkKind(req.Kind)
if kind == "" {
return ErrInvalidManualMark
}
if !req.Clear && req.PositionMs < 0 {
return ErrInvalidManualMark
}
rows := []model.MediaSegment{}
if !req.Clear {
if req.PositionMs <= 0 {
// 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。
return ErrInvalidManualMark
}
rows = append(rows, model.MediaSegment{
MediaID: m.ID,
SeriesID: m.SeriesID,
Kind: kind,
StartMs: manualMarkStart(kind),
EndMs: req.PositionMs,
Source: SegmentSourceManual,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil {
return err
}
if len(rows) > 0 {
s.propagateManualMarkToSeason(ctx, m, rows)
}
return nil
}
// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。
// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。
func normalizeManualMarkKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case model.SegmentKindIntro:
return model.SegmentKindIntro
case model.SegmentKindCredits:
return model.SegmentKindCredits
default:
return ""
}
}
// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户
// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。
func manualMarkStart(kind string) int64 { return 0 }
// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。
//
// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。
// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分
// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标
// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的
// 新标记),两种顺序都会得出错误结果。
func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 {
return
}
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
if err != nil {
s.debug("list season siblings for manual mark failed", m.ID, err)
return
}
for i := range siblings {
sib := &siblings[i]
// 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。
explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual)
if err != nil {
s.debug("list sibling manual segments failed", sib.ID, err)
continue
}
if hasSegmentKind(explicit, rows[0].Kind) {
continue
}
copied := make([]model.MediaSegment, 0, len(rows))
for _, row := range rows {
copied = append(copied, model.MediaSegment{
MediaID: sib.ID,
SeriesID: sib.SeriesID,
Kind: row.Kind,
StartMs: row.StartMs,
EndMs: row.EndMs,
Source: SegmentSourcePropagated,
})
}
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil {
s.debug("propagate manual mark failed", sib.ID, err)
}
}
}
// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
//
// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的
// 账本也不再需要刷新。
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
if s == nil || s.repo == nil || s.introdb == nil {
if s == nil || s.repo == nil || len(s.providers) == 0 {
return 0, nil
}
provider := s.activeProvider(ctx)
if provider == nil {
return 0, nil
}
if limit <= 0 {
@@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
now = s.now()
}
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
)
if err != nil {
return 0, err
@@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
return attempted, err
}
m := &candidates[i]
_, did, err := s.refresh(ctx, m)
_, did, err := s.refreshWithProvider(ctx, m, provider)
if err != nil {
logIntroDBFailure(s.log, m.TMDbID, err)
}
+7 -4
View File
@@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
}
}
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
// 优先级体现「用户意图强度」:manual > propagated > 社区库。
// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。
func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) {
rows := []model.MediaSegment{
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
@@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing
}
got := preferSegmentsBySource(rows)
if len(got) != 2 {
t.Fatalf("got %#v, want manual intro + introdb credits", got)
t.Fatalf("got %#v, want manual intro + propagated credits", got)
}
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
t.Fatalf("intro = %#v, want manual", got[0])
}
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
t.Fatalf("credits = %#v, want theintrodb", got[1])
// 片尾没有 manual,propagated 应当压过社区库。
if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 {
t.Fatalf("credits = %#v, want propagated", got[1])
}
}
+190
View File
@@ -0,0 +1,190 @@
package reader
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件处理「音频直链的签名票过期」这一类上游故障。
//
// 背景:听书源(聆阅·懒人听书这类)的播放地址是第三方中转现签的 CDN 直链,
// 形如 https://vb.stream.tencentmusic.com/long/xxx.m4a?...&vkey=...&ttl=86400000。
// 票的有效性不只取决于时间:中转背后是多个账号 token 轮换,某个 uid/vkey 组合
// 会被 CDN 判掉,此时上游返回**空的 401**(无 Content-Type、无 body),而同一章
// 隔几百毫秒重新签一张票就又能取到。
//
// 旧行为:媒体代理把这个 401 原样透传给播放器,播放器重试的又是同一个 URL,
// 于是 App 日志里出现连续 4~8 个 401;同时音频清单还有 30 分钟缓存,这 30 分钟
// 内 /content 一直下发同一张死票,表现为「这本书怎么都播不了,换本书才行」。
//
// 现在的行为:代理遇到 401/403/410 时先把该章的缓存作废、重新走一次书源取正文,
// 拿到新票再拉一次;新票同时写回缓存,后续请求自然就是有效的。
// mediaRefreshTTL 新票的短缓存:播放器一次失败会立刻用同一 URL 重试多次,
// 这里让并发的重试共用一次回源,避免把书源打成限流。
const mediaRefreshTTL = 2 * time.Minute
// mediaRefreshEntry 一次成功换票的结果。
type mediaRefreshEntry struct {
url string
exp time.Time
}
// mediaRefreshState 保护「旧直链 → 新直链」的短缓存。
type mediaRefreshState struct {
mu sync.Mutex
entries map[string]mediaRefreshEntry
}
// lookupMediaRefresh 查换票短缓存(过期条目顺手清掉)。
func (s *ReaderService) lookupMediaRefresh(staleURL string) (string, bool) {
s.mediaRefresh.mu.Lock()
defer s.mediaRefresh.mu.Unlock()
entry, ok := s.mediaRefresh.entries[staleURL]
if !ok {
return "", false
}
if time.Now().After(entry.exp) {
delete(s.mediaRefresh.entries, staleURL)
return "", false
}
return entry.url, true
}
// rememberMediaRefresh 记住换票结果,并顺手清理过期条目(表很小,遍历即可)。
func (s *ReaderService) rememberMediaRefresh(staleURL, freshURL string) {
s.mediaRefresh.mu.Lock()
defer s.mediaRefresh.mu.Unlock()
if s.mediaRefresh.entries == nil {
s.mediaRefresh.entries = map[string]mediaRefreshEntry{}
}
now := time.Now()
for k, v := range s.mediaRefresh.entries {
if now.After(v.exp) {
delete(s.mediaRefresh.entries, k)
}
}
s.mediaRefresh.entries[staleURL] = mediaRefreshEntry{url: freshURL, exp: now.Add(mediaRefreshTTL)}
}
// RefreshMediaURL 在直链签名失效后重新换一张票。
//
// staleURL 是书源侧产出的原始媒体地址(媒体代理已还原、未加签名)。返回三个值:
//
// refreshable=false 这个地址不是「可换票的音轨」(漫画图片、封面、或并非来自
// 章节缓存的地址),调用方应原样透传上游状态,保持旧行为;
// refreshable=true 且 err!=nil 确实是音轨但换票失败,调用方应给出明确错误,
// 别让播放器对着必然失败的地址反复重试;
// refreshable=true 且 err==nil 拿到了新票。
//
// 换票流程:在书架书籍的章节缓存里找到它所属的那一章 → 作废该章缓存(否则
// /content 会继续下发同一张死票)→ 重新走书源取正文 → 按同一个音轨序号取新地址。
func (s *ReaderService) RefreshMediaURL(ctx context.Context, book *model.ReaderBook, staleURL string) (string, bool, error) {
if s == nil || s.repo == nil || book == nil {
return "", false, nil
}
staleURL = strings.TrimSpace(staleURL)
if staleURL == "" {
return "", false, nil
}
if fresh, ok := s.lookupMediaRefresh(staleURL); ok {
return fresh, true, nil
}
sourceID := s.contentSourceID(ctx, nil, book)
if sourceID == "" {
return "", false, nil
}
bookKey := contentBookKey(sourceID, book.BookURL)
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return "", false, nil
}
for i := range rows {
row := rows[i]
payload, ok := s.readContentPayload(&row)
if !ok || payload.ContentType != "audio" {
continue
}
trackIndex := indexOfString(payload.Tracks, staleURL)
if trackIndex < 0 {
continue
}
ch, ok := s.chapterForCacheRow(ctx, book, row)
if !ok {
return "", true, fmt.Errorf("章节已不在目录中")
}
// 先作废:即使后面重抓失败,也不会继续把死票发给播放器。
s.dropContentCacheRow(ctx, &row)
out, fetchErr := s.fetchChapterContentShared(ctx, book, ch)
if fetchErr != nil {
return "", true, fmt.Errorf("重新取章节失败: %w", fetchErr)
}
if out == nil || trackIndex >= len(out.Tracks) {
return "", true, fmt.Errorf("新内容没有对应的音轨")
}
fresh := strings.TrimSpace(out.Tracks[trackIndex])
if strings.HasPrefix(fresh, "/api/") {
// fetchChapterContentShared 返回缓存原样地址;兜底解析一次代理形态。
return "", true, fmt.Errorf("书源返回了代理地址")
}
if fresh == "" || fresh == staleURL {
return "", true, fmt.Errorf("书源返回了相同地址")
}
s.rememberMediaRefresh(staleURL, fresh)
return fresh, true, nil
}
return "", false, nil
}
// readContentPayload 读取一条章节缓存的落盘载荷。
func (s *ReaderService) readContentPayload(row *model.ReaderContentCache) (cachedChapterContent, bool) {
var payload cachedChapterContent
path := s.contentPathFromRow(row)
if path == "" {
return payload, false
}
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
if err != nil {
return payload, false
}
if err := json.Unmarshal(raw, &payload); err != nil {
return payload, false
}
return payload, true
}
// chapterForCacheRow 按缓存行的章节键找回目录里的章节(remap 后键仍一致)。
func (s *ReaderService) chapterForCacheRow(ctx context.Context, book *model.ReaderBook, row model.ReaderContentCache) (model.ReaderChapter, bool) {
chapters, err := s.repo.ListChapters(ctx, book.ID)
if err != nil {
return model.ReaderChapter{}, false
}
for _, ch := range chapters {
if contentChapterKey(book, ch) == row.ChapterKey {
return ch, true
}
}
// 退一步按序号取:目录里同一章可能因为标题变化换了身份键。
if row.ChapterIndex >= 0 && row.ChapterIndex < len(chapters) && !chapters[row.ChapterIndex].IsVolume {
return chapters[row.ChapterIndex], true
}
return model.ReaderChapter{}, false
}
// indexOfString 返回目标字符串在列表中的下标,找不到返回 -1。
func indexOfString(list []string, target string) int {
target = strings.TrimSpace(target)
for i, item := range list {
if strings.TrimSpace(item) == target {
return i
}
}
return -1
}
@@ -0,0 +1,263 @@
package reader
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 本文件:「音频直链签名票被 CDN 拒掉」后的换票回归测试。
//
// 背景:听书源(聆阅·懒人听书)的播放地址是第三方中转现签的 CDN 直链,票会被
// 上游判掉,此时 CDN 回的是**空的 401**(无 Content-Type、无 body)。旧行为把它
// 原样透传给播放器,播放器重试的又是同一个 URL,于是连续 4~8 个 401,
// App 日志表现为 ERROR_CODE_IO_BAD_HTTP_STATUS (2004)「怎么都播不了」。
//
// 现在的约定(RefreshMediaURL):
// - 能定位到归属章节 → 换票(先作废缓存,再重新走书源取正文);
// - 地址不是音轨(漫画图片、封面等)→ refreshable=false,调用方原样透传;
// - 是音轨但换不到 → refreshable=true + err,调用方给出明确错误不再让播放器重试。
// ticketServer 模拟「中转现签 CDN 直链」的上游:
// - /book/1 详情页(含目录链接)
// - /toc 目录
// - /content 章节正文,每次请求签一张新票
// - /media 媒体本体,只认最新签出的那张票,其余回空的 401
type ticketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
noToken bool // true 时正文里不带票(模拟书源不再给新地址)
}
func newTicketServer() *ticketServer {
s := &ticketServer{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/book/1"):
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><h1>票源之书</h1></body></html>`))
case r.URL.Path == "/content":
s.mu.Lock()
s.hits++
n := s.hits
noToken := s.noToken
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if noToken {
_, _ = w.Write([]byte(`{"data":{"url":""}}`))
return
}
token := fmt.Sprintf("ticket-%d", n)
s.mu.Lock()
s.issued = append(s.issued, token)
s.mu.Unlock()
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token)))
case r.URL.Path == "/media":
// 只认最近签出的那张票;旧票回空的 401(与腾讯 CDN 行为一致)。
token := r.URL.Query().Get("t")
s.mu.Lock()
valid := len(s.issued) > 0 && s.issued[len(s.issued)-1] == token
s.mu.Unlock()
if !valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "audio/mp4")
_, _ = w.Write([]byte("\x00\x00\x00\x20ftypmp42fake-audio-bytes"))
default:
http.NotFound(w, r)
}
}))
return s
}
func (s *ticketServer) contentHits() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits
}
// ticketSourceJSON 一个最小听书源:正文规则取 JSON 里的播放直链。
func ticketSourceJSON(t *testing.T, base string) string {
t.Helper()
return `{
"bookSourceName": "票源测试",
"bookSourceType": 1,
"bookSourceUrl": "` + base + `",
"ruleContent": {"content": "$.data.url"}
}`
}
// prepareTicketBook 建好「听书源 + 书架书籍 + 一章目录」,并开独立缓存目录。
func prepareTicketBook(t *testing.T) (*ReaderService, *ticketServer, *model.ReaderBook) {
t.Helper()
srv := newTicketServer()
t.Cleanup(srv.Close)
svc, _ := newLoginTestService(t)
svc.cfg.Cache.CacheDir = t.TempDir()
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, ticketSourceJSON(t, srv.URL))
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
SourceID: sourceID,
Origin: srv.URL,
OriginName: "票源测试",
OriginType: 1,
BookURL: srv.URL + "/book/1",
}, "票源之书", "某作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
// TestRefreshMediaURLIssuesNewTicket 票被拒后换票:拿到新地址、缓存被刷新。
func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
// 首读:拿到第一张票并落缓存
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d", len(out.Tracks))
}
stale, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
if err != nil {
t.Fatalf("还原直链失败: %v", err)
}
fresh, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil {
t.Fatalf("换票失败: %v", err)
}
if !refreshable {
t.Fatal("音轨地址应当可换票")
}
if fresh == stale {
t.Fatalf("换票后地址没变: %q", fresh)
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("换票应回源重取一次章节,实际请求 %d 次", got)
}
// 新票必须能取到媒体本体(旧票是空的 401)
resp, err := http.Get(fresh)
if err != nil {
t.Fatalf("新票请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("新票状态码 = %d,期望 200", resp.StatusCode)
}
// 缓存要被刷新成新票:再次读章节不应再回源
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("换票后读章节失败: %v", err)
}
refreshed, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out2.Tracks[0], "u"), trackQueryValue(t, out2.Tracks[0], "s"))
if err != nil {
t.Fatalf("还原刷新后的直链失败: %v", err)
}
if refreshed != fresh {
t.Fatalf("章节缓存应刷新为新票 %q,实际 %q", fresh, refreshed)
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("缓存已刷新,不应再回源,实际请求 %d 次", got)
}
}
// TestRefreshMediaURLMemoryReusesTicket 同一张旧票的多次换票只回源一次:
// 播放器一次失败会立刻用同一 URL 重试多次,重复回源会把书源打成限流。
func TestRefreshMediaURLMemoryReusesTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
first, _, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil {
t.Fatalf("首次换票失败: %v", err)
}
for i := 0; i < 3; i++ {
again, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if err != nil || !refreshable {
t.Fatalf("第 %d 次换票失败: refreshable=%v err=%v", i+2, refreshable, err)
}
if again != first {
t.Fatalf("同一旧票应复用同一个新票:%q vs %q", again, first)
}
}
if got := srv.contentHits(); got != 2 {
t.Fatalf("重复换票只该回源一次,实际 %d 次", got)
}
}
// TestRefreshMediaURLSkipsNonTrackURL 漫画图片/防盗链图片这类地址不属于音轨,
// 调用方应原样透传上游状态(不能被换票逻辑吃掉响应体)。
func TestRefreshMediaURLSkipsNonTrackURL(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0); err != nil {
t.Fatalf("首次读取失败: %v", err)
}
_, refreshable, err := svc.RefreshMediaURL(ctx, book, "https://img.example.com/cover.jpg")
if err != nil {
t.Fatalf("非音轨地址不该报错: %v", err)
}
if refreshable {
t.Fatal("非音轨地址不该被判为可换票")
}
}
// TestRefreshMediaURLErrorsWhenSourceRepeatsTicket 书源重新签出同一张票时,
// 说明中转本身没换票,应当明确失败而不是让播放器继续对着死票重试。
func TestRefreshMediaURLErrorsWhenSourceRepeatsTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
stale, _ := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
// 让书源此后只回空地址:换票必然失败。
srv.mu.Lock()
srv.noToken = true
srv.mu.Unlock()
_, refreshable, err := svc.RefreshMediaURL(ctx, book, stale)
if !refreshable {
t.Fatal("音轨地址应当被判为可换票")
}
if err == nil {
t.Fatal("书源给不出新票时应当报错")
}
// 作废过的缓存不该把死票留在库里
if _, ok := svc.loadCachedContent(ctx, nil, nil, book, model.ReaderChapter{
Index: 0, Title: "第一章", URL: srv.URL + "/content",
}, "audio"); ok {
t.Fatal("换票失败后旧缓存应已被作废,不应继续下发死票")
}
}
+70
View File
@@ -5,11 +5,13 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
)
@@ -132,3 +134,71 @@ func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
}
}
// TestMediaHTTPClientHasNoOverallTimeout 媒体客户端不能用「整体超时」。
//
// 这是本次故障的第二个根因:http.Client.Timeout 覆盖「读完 body」这一阶段。
// 一个 4MB 的 m4a 在跨境链路上很容易超过 30 秒,超时后 net/http 直接掐断
// 响应体,而调用方的 io.Copy 只是正常返回——表现为**静默截断**(播放器拿到
// 半首歌,判「格式不支持」)或 502。所以媒体客户端必须没有整体超时。
func TestMediaHTTPClientHasNoOverallTimeout(t *testing.T) {
client := helper.NewMediaHTTPClient(false)
if client.Timeout != 0 {
t.Fatalf("媒体 client 的整体 Timeout = %v,必须为 0(大文件下载会被静默截断)", client.Timeout)
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("Transport 类型 = %T", client.Transport)
}
if tr.ResponseHeaderTimeout <= 0 {
t.Fatal("媒体 client 应设置 ResponseHeaderTimeout,避免上游不给首字节时无限挂起")
}
// 必须走 HTTP/1.1:腾讯 CDN 的 h2 边缘在长连接大文件上不回 WINDOW_UPDATE,
// Go 的 h2 初始连接窗口是 1MiB,下载会稳定断在 1MiB/2MiB/3MiB 整数倍处。
if tr.ForceAttemptHTTP2 {
t.Error("媒体 client 必须关闭 HTTP/2(1MiB 窗口截断)")
}
if tr.TLSNextProto == nil || len(tr.TLSNextProto) != 0 {
t.Error("媒体 client 需要清空 TLSNextProto,才能确保不用 h2")
}
}
// TestFetchMediaStreamsLargeBodyWithoutTruncation 大响应体必须完整送达。
//
// 用「慢速分块发送」模拟跨境链路上的大音频:总耗时超过规则请求的 30 秒超时,
// 但媒体通路必须把每一个字节都交出来,绝不静默截断。
func TestFetchMediaStreamsLargeBodyWithoutTruncation(t *testing.T) {
const totalBytes = 24 << 20 // 24MB
const chunk = 512 << 10
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Content-Length", strconv.Itoa(totalBytes))
payload := make([]byte, chunk)
for off := 0; off < totalBytes; off += chunk {
if _, err := w.Write(payload); err != nil {
return
}
w.(http.Flusher).Flush()
time.Sleep(5 * time.Millisecond)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
book := &model.ReaderBook{Origin: srv.URL}
resp, err := svc.FetchMedia(t.Context(), book, srv.URL+"/big.m4a", "")
if err != nil {
t.Fatalf("FetchMedia 失败: %v", err)
}
defer resp.Body.Close()
got, readErr := io.Copy(io.Discard, resp.Body)
if readErr != nil {
t.Fatalf("读取响应体失败: %v", readErr)
}
if got != totalBytes {
t.Fatalf("交付字节 = %d,期望 %d(少了 %d 字节 → 播放器会判格式不支持)",
got, totalBytes, int64(totalBytes)-got)
}
}
+29
View File
@@ -86,6 +86,9 @@ const (
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
//
// 注意 401 不在这里:书源的规则请求返回 401 通常是「登录态失效」,反复重试
// 只会白等退避时间。媒体直链另有语义,见 mediaRetryableStatus。
func retryableStatus(code int) bool {
switch code {
case 403, 408, 425, 429, 500, 502, 503, 504:
@@ -95,6 +98,32 @@ func retryableStatus(code int) bool {
}
}
// mediaRetryableStatus 媒体拉取专用的可重试判定。
//
// 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于
// 上游抖动,同一张票稍后重试可能就恢复;而 401 是「签名票被拒」,重试同一张票
// 一定还是 401,必须由调用方回源换票(见 MediaNeedsRefresh)。
// 所以这里只补 410,不把 401 加进来。
func mediaRetryableStatus(code int) bool {
if retryableStatus(code) {
return true
}
return code == 410
}
// MediaNeedsRefresh 判断媒体上游状态是否值得「换一张签名票再试」。
//
// 401 是 CDN 对无效 vkey/uid 的固定回答(空 body、无 Content-Type),410 是
// 对象已下线,403 常见于票与资源不匹配。这三类都值得回到书源重新签票。
func MediaNeedsRefresh(code int) bool {
switch code {
case 401, 403, 410:
return true
default:
return false
}
}
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
func sleepWithContext(ctx context.Context, d time.Duration) bool {
if d <= 0 {
@@ -71,3 +71,36 @@ func TestRetryableStatus(t *testing.T) {
}
}
}
// TestMediaRetryableStatus 媒体直链的 410(对象已下线)与 403/429 一样值得原地重试,
// 但 401「签名票被拒」原地重试一定是同样的结果,只能靠回源换票。
func TestMediaRetryableStatus(t *testing.T) {
for _, code := range []int{403, 410, 429, 500, 503} {
if !mediaRetryableStatus(code) {
t.Errorf("媒体状态码 %d 应当可重试", code)
}
}
for _, code := range []int{200, 206, 301, 400, 401, 404} {
if mediaRetryableStatus(code) {
t.Errorf("媒体状态码 %d 不该原地重试", code)
}
}
// 规则请求的判定不能被放宽:登录态失效的 401 反复重试只会白等。
if retryableStatus(401) || retryableStatus(410) {
t.Error("retryableStatus 不应包含 401/410")
}
}
// TestMediaNeedsRefresh 只有「票被拒」这一类状态才值得回源换票。
func TestMediaNeedsRefresh(t *testing.T) {
for _, code := range []int{401, 403, 410} {
if !MediaNeedsRefresh(code) {
t.Errorf("状态码 %d 应当换票重试", code)
}
}
for _, code := range []int{200, 206, 304, 400, 404, 429, 500} {
if MediaNeedsRefresh(code) {
t.Errorf("状态码 %d 不该换票", code)
}
}
}
+26 -4
View File
@@ -43,7 +43,14 @@ type ReaderService struct {
log *zap.Logger
repo *repository.ReaderRepository
http *http.Client
crypto *helper.SecretCipher
// mediaHTTP 用于转发媒体流(音频/视频/漫画大图)。
//
// 必须与 http 分开:http 设了 30 秒的「整体」超时(含读完 body),而一个
// 4MB 的音频在跨境链路上很容易超过它,超时后 net/http 直接掐断 body,
// 调用方的 io.Copy 却只是正常返回 —— 表现为静默截断(播放器拿到半首歌)
// 或 502。见 helper.NewMediaHTTPClient 的说明。
mediaHTTP *http.Client
crypto *helper.SecretCipher
// browserMu / browserPending 保护「待用户完成的页面」表
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
@@ -71,6 +78,10 @@ type ReaderService struct {
// 阅读页与批量预取会并发请求同一章(见 fetchChapterContentShared)。
contentFlightsMu sync.Mutex
contentFlights map[string]*contentFlight
// mediaRefresh 记录「已失效直链 → 重新签到的新直链」的短缓存:
// CDN 判掉某张签名票后,播放器的多次重试共用一次回源(见 media_refresh.go)。
mediaRefresh mediaRefreshState
}
// NewReaderService 创建服务。
@@ -83,12 +94,21 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con
log: log,
repo: repos.Reader,
http: client,
mediaHTTP: helper.NewMediaHTTPClient(true),
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
limiter: newSourceRateLimiter(),
coverDecodeOrigins: map[string]bool{},
}
}
// mediaClient 返回转发媒体流用的客户端(未注入时退回规则请求的 client)。
func (s *ReaderService) mediaClient() *http.Client {
if s != nil && s.mediaHTTP != nil {
return s.mediaHTTP
}
return s.http
}
// markSourceCoverDecode 记录某书源是否声明了 coverDecodeJs(导入/更新书源时调用)。
func (s *ReaderService) markSourceCoverDecode(sourceURL string, enabled bool) {
if s == nil || strings.TrimSpace(sourceURL) == "" {
@@ -1942,12 +1962,14 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
if err != nil {
return nil, err
}
resp, err := s.http.Do(httpReq)
// 媒体用无整体超时的 client:4MB 音频在跨境链路上常超过 30 秒,
// 用带整体超时的 client 会在读完 body 前被掐断(静默截断)。
resp, err := s.mediaClient().Do(httpReq)
if err != nil {
lastErr = err
continue
}
if attempt == mediaFetchAttempts || !retryableStatus(resp.StatusCode) {
if attempt == mediaFetchAttempts || !mediaRetryableStatus(resp.StatusCode) {
return resp, nil
}
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
@@ -2511,7 +2533,7 @@ func (s *ReaderService) fetchSourceResource(ctx context.Context, sourceURL, rawU
httpReq.Header.Set("Referer", referer)
}
}
return s.http.Do(httpReq)
return s.mediaClient().Do(httpReq)
}
// ─── 书架 ──────────────────────────────────────────────────────────────────
+46
View File
@@ -0,0 +1,46 @@
// Package service — 片头/片尾片段的外部提供方抽象。
//
// 目前有三个社区库可用,它们对「媒体身份」的要求各不相同:
//
// TheIntroDB TMDb id(服务端自己映射到 TVDB)
// IntroDB 只有 IMDb id(用 is_movie 区分电影/剧集)
// TODb TMDb id + 平台类型(tmdb_id_tv / tmdb_id_movie),秒级时间轴
//
// 播放链路只关心「一段可跳过的区间」,所以这里把三者收敛成同一个
// SegmentProvider 接口和 SegmentQuery 入参,上层按设置项挑选其一。
package service
import "context"
// SegmentSpan 是一个已解析的可跳过区间,单位为毫秒。
//
// EndMs == 0 是哨兵值,表示「一直到片尾」:只有片尾(credits/preview)可能
// 出现这种区间,由客户端结合媒体总时长补齐。片头/回顾不允许开放区间,
// 拿不到结束点就整段丢弃——否则会得到一个覆盖全片的区间。
type SegmentSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBSpan 保留旧名字,避免既有调用方和测试大范围改名。
type IntroDBSpan = SegmentSpan
// SegmentQuery 是一次片段查询的身份参数。不同提供方按需取用其中一部分。
type SegmentQuery struct {
TMDbID int
IMDbID string
Season int
Episode int
// IsMovie 为 true 时 Season/Episode 无意义。
IsMovie bool
}
// SegmentProvider 是一个外部片段数据源。
type SegmentProvider interface {
// Source 返回写入 media_segments.source 的标识,也是缓存账本的键。
Source() string
// FetchSegments 返回该媒体已知的可跳过区间。查不到不是错误(返回空切片),
// 只有请求本身失败才返回 error——调用方据此决定是否保留旧缓存。
FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error)
}
+150
View File
@@ -0,0 +1,150 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"go.uber.org/zap"
)
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)对「没有数值」有两种
// 写法,含义完全不同:end_ms: null = 一直到片尾;end_ms: 0 = 这一段不存在。
// 实测约三分之一的剧集会返回 {"start_ms":null,"end_ms":0} 的 intro 占位。
const (
// 真实返回:intro 是占位、credits 到片尾。
introDBNoSegmentPayload = `{"tvdb_id":73762,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":null,"end_ms":0}],"credits":[{"start_ms":2574000,"end_ms":null}]}`
// 整集都是占位:recap/credits 都是 (0,0)。
introDBAllPlaceholderPayload = `{"tvdb_id":375921,"type":"tv","season":1,"episode":1,"recap":[{"start_ms":0,"end_ms":0}],"credits":[{"start_ms":0,"end_ms":0}]}`
)
// 把 end_ms: 0 当成「到片尾」会让 intro 变成 [0, 片长],整集都是片头:
// 一开播就弹跳过按钮,开了自动跳过直接跳到片尾。
func TestParseIntroDBResponseDropsZeroEndPlaceholders(t *testing.T) {
spans, err := parseIntroDBResponse([]byte(introDBNoSegmentPayload))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 {
t.Fatalf("spans = %#v, want only the credits range", spans)
}
if spans[0].Kind != "credits" || spans[0].StartMs != 2_574_000 || spans[0].EndMs != 0 {
t.Fatalf("credits span = %#v, want start 2574000 and open end", spans[0])
}
}
func TestParseIntroDBResponseDropsAllPlaceholderSegments(t *testing.T) {
spans, err := parseIntroDBResponse([]byte(introDBAllPlaceholderPayload))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 0 {
t.Fatalf("spans = %#v, want none for all-placeholder payload", spans)
}
}
// 片头/回顾不允许开放结束点:即使数据源给了 null,也不能补齐成「一直到片尾」。
func TestParseIntroDBResponseRejectsOpenEndedIntro(t *testing.T) {
body := `{"tvdb_id":1,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":1000,"end_ms":null}]}`
spans, err := parseIntroDBResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 0 {
t.Fatalf("spans = %#v, want an open-ended intro to be dropped", spans)
}
}
// IntroDB 的响应把区间给成对象而不是数组,且片尾字段叫 outro。
func TestParseIntroDBAppResponseMapsKinds(t *testing.T) {
body := `{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,"season":1,"episode":1,
"intro":{"start_ms":437000,"end_ms":531000},
"recap":null,
"outro":{"start_ms":3431000,"end_ms":3500000},
"post_credits":{"start_ms":3600000,"end_ms":3610000}}`
spans, err := parseIntroDBAppResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
want := []struct {
kind string
start int64
end int64
}{
{"intro", 437_000, 531_000},
{"credits", 3_431_000, 3_500_000},
{"preview", 3_600_000, 3_610_000},
}
if len(spans) != len(want) {
t.Fatalf("spans = %#v, want %d", spans, len(want))
}
for i, w := range want {
if spans[i].Kind != w.kind || spans[i].StartMs != w.start || spans[i].EndMs != w.end {
t.Fatalf("span[%d] = %#v, want %s %d-%d", i, spans[i], w.kind, w.start, w.end)
}
}
}
// IntroDB 只认 IMDb id:没有 IMDb id 时不能退化成用 TMDb id 拼请求,
// 那只会在服务端拿到 400,被误记成「查询失败」而保留旧缓存。
func TestIntroDBAppFetchSkipsWithoutIMDbID(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
spans, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 1396, Season: 1, Episode: 1})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(spans) != 0 || calls != 0 {
t.Fatalf("spans = %#v calls = %d, want no request without an imdb id", spans, calls)
}
}
func TestIntroDBAppFetchSendsIMDbAndSeasonEpisode(t *testing.T) {
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,
"intro":{"start_ms":1000,"end_ms":2000}}`))
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{
IMDbID: "tt0903747", Season: 2, Episode: 3,
}); err != nil {
t.Fatalf("fetch: %v", err)
}
for _, want := range []string{"imdb_id=tt0903747", "season=2", "episode=3"} {
if !strings.Contains(gotQuery, want) {
t.Fatalf("query = %q, want it to contain %q", gotQuery, want)
}
}
}
// 电影要用 is_movie,且不能带季集参数。
func TestIntroDBAppFetchUsesIsMovieForMovies(t *testing.T) {
var gotQuery string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
_, _ = w.Write([]byte(`{"imdb_id":"tt1375666","is_movie":true}`))
}))
defer server.Close()
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{IMDbID: "tt1375666", IsMovie: true}); err != nil {
t.Fatalf("fetch: %v", err)
}
if !strings.Contains(gotQuery, "is_movie=true") {
t.Fatalf("query = %q, want is_movie=true", gotQuery)
}
if strings.Contains(gotQuery, "season=") || strings.Contains(gotQuery, "episode=") {
t.Fatalf("query = %q, want no season/episode for a movie", gotQuery)
}
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// stubIMDbResolver 返回固定 IMDb id,用于验证 IntroDB 的 id 解析链路。
type stubIMDbResolver struct {
id string
calls int32
}
func (s *stubIMDbResolver) ResolveIMDbID(_ context.Context, _ int, _ bool) (string, error) {
atomic.AddInt32(&s.calls, 1)
return s.id, nil
}
func TestSegmentSourceOffSkipsExternalLookup(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, SegmentSourceOff); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-off"}, Path: "/movies/off.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
t.Fatalf("rows = %#v calls = %d, want no lookup when the source is off", rows, calls)
}
}
// 默认(未配置设置项)必须保持历史行为:用 TheIntroDB。
func TestSegmentSourceDefaultsToTheIntroDB(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
m := &model.Media{Base: model.Base{ID: "mv-default"}, Path: "/movies/default.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 1 || atomic.LoadInt32(&calls) != 1 {
t.Fatalf("rows = %#v calls = %d, want the default provider to run", rows, calls)
}
}
// 切到 TODb 后,TheIntroDB 不应再被调用,写入的 source 也要跟着变。
func TestSegmentSourceSwitchesToTODb(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var introCalls, todbCalls int32
introServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&introCalls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer introServer.Close()
todbServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&todbCalls, 1)
_, _ = w.Write([]byte(`{"items":[{"marker":[{"marker_type":"intro","time_start":5,"time_end":30}]}]}`))
}))
defer todbServer.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(introServer.URL)).
SetTODb(NewTODbService(zap.NewNop()).SetBaseURL(todbServer.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, TODbSource); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-todb"}, Path: "/movies/todb.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if atomic.LoadInt32(&introCalls) != 0 {
t.Fatalf("theintrodb was called %d times, want 0 after switching source", introCalls)
}
if atomic.LoadInt32(&todbCalls) != 1 {
t.Fatalf("todb calls = %d, want 1", todbCalls)
}
if len(rows) != 1 || rows[0].Source != TODbSource {
t.Fatalf("rows = %#v, want one row tagged %s", rows, TODbSource)
}
if rows[0].StartMs != 5000 || rows[0].EndMs != 30000 {
t.Fatalf("row = %#v, want 5000-30000ms", rows[0])
}
}
// 未知的源标识(降级回滚残留)要退回默认源,而不是静默失效。
func TestSegmentSourceFallsBackForUnknownValue(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(introDBMoviePayload))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, "no-such-source"); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-unknown"}, Path: "/movies/u.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if _, err := svc.ListForPlayback(t.Context(), m); err != nil {
t.Fatalf("list: %v", err)
}
if atomic.LoadInt32(&calls) != 1 {
t.Fatalf("calls = %d, want the default provider to still run", calls)
}
}
// IntroDB 需要 IMDb id;解析不出来时必须跳过请求,且不能写负缓存
// (否则元数据补齐之后也永远不会再查)。
func TestSegmentIntroDBSkipsWhenIMDbUnavailable(t *testing.T) {
repos := repository.New(newServiceTestDB(t))
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()
svc := NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDBApp(NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)).
SetIMDbResolver(&stubIMDbResolver{id: ""})
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, IntroDBAppSource); err != nil {
t.Fatal(err)
}
m := &model.Media{Base: model.Base{ID: "mv-noimdb"}, Path: "/movies/noimdb.mkv", TMDbID: 27205}
if err := repos.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
rows, err := svc.ListForPlayback(t.Context(), m)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
t.Fatalf("rows = %#v calls = %d, want no request without an imdb id", rows, calls)
}
ledger, err := repos.MediaSegment.GetFetch(t.Context(), "mv-noimdb", IntroDBAppSource)
if err != nil {
t.Fatal(err)
}
if ledger != nil {
t.Fatalf("ledger = %#v, want no negative cache entry", ledger)
}
}
+8 -2
View File
@@ -134,8 +134,14 @@ func (b *serviceContainerBuilder) initContentServices() {
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
// 片头/片尾片段:播放时按需向 TheIntroDB 补齐并落库,供下次直接命中。
b.c.Segments = NewMediaSegmentService(b.log, b.repos).SetIntroDB(NewIntroDBService(b.log))
// 片头/片尾片段:播放时按需向社区库补齐并落库,供下次直接命中。
// 三个源都注册,具体用哪个由设置项 segment.source 决定(默认 theintrodb)。
b.c.Segments = NewMediaSegmentService(b.log, b.repos).
SetIntroDB(NewIntroDBService(b.log)).
SetIntroDBApp(NewIntroDBAppService(b.log)).
SetTODb(NewTODbService(b.log)).
// IntroDB 只认 IMDb id,需要经 TMDb 的 external_ids 解析。
SetIMDbResolver(b.c.TMDb)
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos)
+33
View File
@@ -20,6 +20,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
@@ -133,3 +135,34 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
}
return json.NewDecoder(resp.Body).Decode(out)
}
// ResolveIMDbID 用 TMDb 的 external_ids 端点把 TMDb 条目解析成 IMDb id。
//
// 这是为只认 IMDb id 的片段源(IntroDB)准备的。结果不做本地缓存:TMDb 的
// external_ids 响应很小,而片段查询本身已经有 30 天/24 小时的正负缓存兜底,
// 再叠一层只会引入失效问题。
//
// TMDb 未配置或上游不可达时返回空字符串与 error,调用方据此跳过该源,
// 而不是把所有媒体都记成「查不到」。
func (t *TMDbProvider) ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error) {
if t == nil || t.cfg == nil || tmdbID <= 0 {
return "", nil
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return "", nil
}
mediaType := "tv"
if isMovie {
mediaType = "movie"
}
u := t.resolveBaseURL(ctx) + "/" + mediaType + "/" + fmt.Sprint(tmdbID) +
"/external_ids?api_key=" + url.QueryEscape(apiKey)
var r struct {
IMDbID string `json:"imdb_id"`
}
if err := t.getJSON(ctx, u, &r); err != nil {
return "", err
}
return strings.TrimSpace(r.IMDbID), nil
}
+213
View File
@@ -0,0 +1,213 @@
// Package service — TODb (TheOtherDB) segment client.
//
// TODb 的播放资料库按「片源」组织:同一个 TMDB 条目可能有爱奇艺、腾讯等多个
// 版本的 marker,每个版本自带 version_runtime(该片源的时长)。因此它的响应是
// 一个 items 数组,而不是单一区间集合。
//
// 接口是 POST + JSON body(GET 会返回 405),平台用 platform_type 表达:
//
// tmdb_id_tv + season_number / episode_number
// tmdb_id_movie 无季集
//
// marker 的时间轴是**秒**(time_start / time_end),不是毫秒。
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"go.uber.org/zap"
)
// TODbBaseURL 是公开只读端点。
const TODbBaseURL = "https://playback.theotherdb.org/api/metadata"
// TODbSource 写入 media_segments.source。
const TODbSource = "todb"
const (
todbTimeout = 10 * time.Second
todbMaxBodySize = 1 << 20
// todbPageSize 一次取多少条版本记录。社区库大多数条目只有一个片源,
// 但同一部剧的各平台版本可能都有人标,取多一点可以避免漏掉匹配时长的那个。
todbPageSize = 10
)
// todbRequest 是 POST /api/metadata 的请求体。
type todbRequest struct {
PlatformType string `json:"platform_type"`
PlatformValue string `json:"platform_value"`
SeasonNumber int `json:"season_number,omitempty"`
EpisodeNumber int `json:"episode_number,omitempty"`
Includes []string `json:"includes"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// todbMarker 是单个标记点。time_start/time_end 单位是秒。
type todbMarker struct {
MarkerType string `json:"marker_type"`
TimeStart *float64 `json:"time_start"`
TimeEnd *float64 `json:"time_end"`
}
// todbItem 是一个片源版本及其 marker。
type todbItem struct {
VersionID int `json:"version_id"`
VersionName string `json:"version_name"`
VersionRuntime *int `json:"version_runtime"`
VideoType string `json:"video_type"`
Marker []todbMarker `json:"marker"`
}
type todbResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
Items []todbItem `json:"items"`
}
// TODbService 查询 TODb 的片段数据。
type TODbService struct {
log *zap.Logger
client *http.Client
baseURL string
pageSize int
}
// NewTODbService is the constructor.
func NewTODbService(log *zap.Logger) *TODbService {
return &TODbService{
log: log,
client: NewExternalHTTPClient(todbTimeout),
baseURL: TODbBaseURL,
pageSize: todbPageSize,
}
}
// SetBaseURL overrides the API endpoint (tests, mirrors).
func (s *TODbService) SetBaseURL(base string) *TODbService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimSpace(base)
}
return s
}
// Source implements SegmentProvider.
func (s *TODbService) Source() string { return TODbSource }
// FetchSegments implements SegmentProvider.
//
// 返回时会把多个版本的 marker 合并:同 kind 取第一个出现的区间。TODb 的片源
// 时长各不相同,严格按 version_runtime 匹配当前媒体需要可靠的总时长,而片段
// 查询经常跑在时长还不知道的时候(STRM 媒体),所以这里不做版本筛选——宁可
// 给一个近似位置,也好过整段缺失。真正需要精度的场景由 manual 标记覆盖。
func (s *TODbService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if s == nil || s.client == nil || q.TMDbID <= 0 {
return nil, nil
}
payload := todbRequest{
PlatformValue: fmt.Sprint(q.TMDbID),
Includes: []string{"marker"},
Page: 1,
PageSize: s.pageSize,
}
if q.IsMovie {
payload.PlatformType = "tmdb_id_movie"
} else {
payload.PlatformType = "tmdb_id_tv"
payload.SeasonNumber = q.Season
payload.EpisodeNumber = q.Episode
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("todb: unexpected status %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, todbMaxBodySize))
if err != nil {
return nil, err
}
return parseTODbResponse(raw)
}
// parseTODbResponse 把各片源的 marker 合并成 spans。
//
// marker_type 的映射沿用报告里的规则:intro -> intro,credits/post_credits ->
// credits(片尾),其余(chapter 等章节标记)忽略——章节标题没有可靠的语义,
// 当成片头会让跳过点漂到任意位置。
func parseTODbResponse(body []byte) ([]SegmentSpan, error) {
var raw todbResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse todb json: %w", err)
}
seen := make(map[string]bool, 4)
spans := make([]SegmentSpan, 0, 4)
for _, item := range raw.Items {
for _, marker := range item.Marker {
kind := todbMarkerKind(marker.MarkerType)
if kind == "" || seen[kind] {
continue
}
if marker.TimeStart == nil || marker.TimeEnd == nil {
continue
}
startMs := int64(*marker.TimeStart * 1000)
endMs := int64(*marker.TimeEnd * 1000)
if startMs < 0 {
startMs = 0
}
// 结束点缺失或非正表示「到片尾」,只有这样才是合法的开放区间;
// 与 TheIntroDB 不同,TODb 用 0 而不是 null 表示没标结束点,
// 所以这里把 <= start 的片尾当作开放区间。
if endMs <= startMs {
if kind != "credits" && kind != "preview" {
continue
}
endMs = 0
}
spans = append(spans, SegmentSpan{Kind: kind, StartMs: startMs, EndMs: endMs})
seen[kind] = true
}
}
return spans, nil
}
// todbMarkerKind 把 TODb 的 marker_type 映射成库内 kind。
func todbMarkerKind(markerType string) string {
switch strings.ToLower(strings.TrimSpace(markerType)) {
case "intro":
return "intro"
case "recap":
return "recap"
case "credits", "post_credits", "outro":
return "credits"
case "preview":
return "preview"
default:
return ""
}
}
+112
View File
@@ -0,0 +1,112 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
)
// TODb 的接口是 POST + JSON body(GET 返回 405),平台类型用 tmdb_id_tv /
// tmdb_id_movie 表达,marker 的时间轴是秒。
func TestTODbFetchSendsPostWithTMDBPlatform(t *testing.T) {
var body map[string]any
var method string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"page":1,"page_size":10,"has_more":false,"items":[]}`))
}))
defer server.Close()
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 226045, Season: 1, Episode: 1}); err != nil {
t.Fatalf("fetch: %v", err)
}
if method != http.MethodPost {
t.Fatalf("method = %s, want POST", method)
}
if body["platform_type"] != "tmdb_id_tv" || body["platform_value"] != "226045" {
t.Fatalf("body = %#v, want tmdb_id_tv/226045", body)
}
if body["season_number"] != float64(1) || body["episode_number"] != float64(1) {
t.Fatalf("body = %#v, want season/episode 1", body)
}
}
func TestTODbFetchUsesMoviePlatform(t *testing.T) {
var body map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &body)
_, _ = w.Write([]byte(`{"items":[]}`))
}))
defer server.Close()
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 83533, IsMovie: true}); err != nil {
t.Fatalf("fetch: %v", err)
}
if body["platform_type"] != "tmdb_id_movie" {
t.Fatalf("platform_type = %v, want tmdb_id_movie", body["platform_type"])
}
if _, ok := body["season_number"]; ok {
t.Fatalf("body = %#v, want no season_number for a movie", body)
}
}
// 实测返回:marker 的时间是秒,marker_type 为 intro。
func TestParseTODbResponseConvertsSecondsToMilliseconds(t *testing.T) {
body := `{"page":1,"page_size":3,"has_more":false,"items":[
{"version_id":1192,"version_name":"爱奇艺","version_runtime":1300,"video_type":"tv",
"marker":[{"marker_id":1588,"marker_type":"intro","time_start":0,"time_end":27}]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 {
t.Fatalf("spans = %#v, want 1", spans)
}
if spans[0].Kind != "intro" || spans[0].StartMs != 0 || spans[0].EndMs != 27_000 {
t.Fatalf("span = %#v, want intro 0-27000ms", spans[0])
}
}
func TestParseTODbResponseMapsMarkerTypes(t *testing.T) {
body := `{"items":[{"marker":[
{"marker_type":"intro","time_start":10,"time_end":30},
{"marker_type":"credits","time_start":100,"time_end":120},
{"marker_type":"post_credits","time_start":130,"time_end":140},
{"marker_type":"chapter","time_start":5,"time_end":6}
]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
// chapter 被忽略;post_credits 与 credits 同为片尾,同 kind 只保留第一个。
if len(spans) != 2 {
t.Fatalf("spans = %#v, want intro + credits", spans)
}
if spans[0].Kind != "intro" || spans[1].Kind != "credits" {
t.Fatalf("kinds = %s/%s, want intro/credits", spans[0].Kind, spans[1].Kind)
}
}
// 片尾的结束点缺失或非正表示「到片尾」,是合法的开放区间;片头不行。
func TestParseTODbResponseAllowsOpenEndedCreditsOnly(t *testing.T) {
body := `{"items":[{"marker":[
{"marker_type":"intro","time_start":10,"time_end":10},
{"marker_type":"credits","time_start":100,"time_end":100}
]}]}`
spans, err := parseTODbResponse([]byte(body))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(spans) != 1 || spans[0].Kind != "credits" || spans[0].EndMs != 0 {
t.Fatalf("spans = %#v, want only an open-ended credits", spans)
}
}
File diff suppressed because one or more lines are too long
+10
View File
@@ -54,6 +54,16 @@ export const playbackAPI = {
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
.then((r) => r.data),
// 手动标记:把当前位置记为片头结束 / 片尾开始(clear 则清除)。服务端会按季
// 传播到同季其它尚未标记的集,并返回合并后的完整片段列表。
markSegment: (
mediaId: string,
payload: { kind: 'intro' | 'credits'; position_ms?: number; clear?: boolean },
) =>
api
.put<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`, payload)
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data),
+53 -10
View File
@@ -79,6 +79,15 @@ const SEEK_STEP_SEC = 10
const SEEK_REPEAT_MS = 160
const SEEK_APPLY_MS = 220
const SEEK_HINT_MS = 700
/**
* 一次 seek 之后,播放事件在这段时间内不得把控制栏唤出来。
*
* 方向键/进度条跳转会让 <video> 走一遍 seeking → waiting → playing:
* 直连要重新拉 Range,本地 HLS 甚至要重起一段转码,缓冲恢复时的 playing
* 事件会把「播放器自己恢复」误当成用户操作,控制栏(连同整条进度条)就又
* 冒出来了。键盘处理本身早就不再唤出控制栏,漏的就是这条事件链。
*/
const SEEK_CONTROLS_SUPPRESS_MS = 1500
const PLAYBACK_RATE_REPEAT_MS = 140
const PLAYBACK_RATE_HINT_MS = 900
/** 播放中控制栏无操作自动隐藏的延时。 */
@@ -290,6 +299,10 @@ export function PlayerControls({
const vr360ActiveRef = useRef(Boolean(vr360))
/** 画面是否已锁住(VR 锁)。事件回调里读它,避免闭包读到过期值。 */
const uiLockedRef = useRef(uiLocked)
/** 控制栏当前是否可见:方向键只续期计时器,不改变可见性。 */
const uiVisibleRef = useRef(uiVisible)
/** 最近一次真正下发 seek 的时间(含键盘与进度条),用于压掉 seek 引发的播放事件。 */
const lastSeekActivityRef = useRef(0)
/** VR 画布上的按下状态:按住拖动 = 转动视角,不唤出控制栏。 */
const vrDragRef = useRef<{ x: number; y: number; moved: boolean } | null>(null)
const pendingSeekRef = useRef<number | null>(null)
@@ -318,6 +331,10 @@ export function PlayerControls({
uiLockedRef.current = uiLocked
}, [uiLocked])
useEffect(() => {
uiVisibleRef.current = uiVisible
}, [uiVisible])
// 「保持显示」条件集中在这里:悬停控制栏、拖动进度条、打开任何菜单或面板。
// 事件回调里读 ref,渲染里读派生值,两边始终一致。
const keepControlsVisible =
@@ -487,10 +504,27 @@ export function PlayerControls({
onUiVisibleChange(false)
}
/**
* 播放状态变化:图标永远同步,但只有「暂停」这种明确需要操作栏的动作才
* 主动唤出控制栏。
*
* `playing` 在 seek 之后缓冲恢复时同样会派发(直连要重拉 Range、本地 HLS
* 甚至重起一段转码),把它当成用户操作,方向键快进时整条控制栏(进度条)
* 就会自己冒出来。所以 play / playing 只同步图标并续期自动隐藏计时器,
* 不改变可见性;pause 才唤出控制栏,并且要避开刚下发 seek 的那一小段时间。
*/
const seekSuppressed = () =>
Date.now() - lastSeekActivityRef.current < SEEK_CONTROLS_SUPPRESS_MS
const syncPlay = () => {
setPlaying(!el.paused)
if (seekSuppressed()) return
onMove()
}
/** 播放/缓冲恢复:只同步状态并续期隐藏计时器,绝不主动唤出控制栏。 */
const syncPlaying = () => {
setPlaying(!el.paused)
resetTimer()
}
const syncTime = () => {
const ranges = el.buffered
if (ranges && ranges.length > 0) {
@@ -539,8 +573,11 @@ export function PlayerControls({
stage.addEventListener('pointerup', endVrDrag)
stage.addEventListener('pointercancel', endVrDrag)
el.addEventListener('play', syncPlay)
el.addEventListener('playing', syncPlay)
// 播放开始/缓冲恢复都不唤出控制栏:播放本身几乎总是由点击画面、播放按钮
// 或 seek 触发,这些入口要么已经在控制栏里,要么会被显式显示;事件再顶一次
// 就会让方向键快进时整条进度条冒出来。只有暂停需要主动亮出控制栏。
el.addEventListener('play', syncPlaying)
el.addEventListener('playing', syncPlaying)
el.addEventListener('pause', syncPlay)
el.addEventListener('timeupdate', syncTime)
el.addEventListener('progress', syncTime)
@@ -561,8 +598,8 @@ export function PlayerControls({
stage.removeEventListener('pointermove', onVrPointerMove)
stage.removeEventListener('pointerup', endVrDrag)
stage.removeEventListener('pointercancel', endVrDrag)
el.removeEventListener('play', syncPlay)
el.removeEventListener('playing', syncPlay)
el.removeEventListener('play', syncPlaying)
el.removeEventListener('playing', syncPlaying)
el.removeEventListener('pause', syncPlay)
el.removeEventListener('timeupdate', syncTime)
el.removeEventListener('progress', syncTime)
@@ -632,6 +669,8 @@ export function PlayerControls({
(absolute: number) => {
const el = video()
if (!el) return
// 记下这次 seek:随后由它触发的 waiting/playing 不算用户操作。
lastSeekActivityRef.current = Date.now()
if (onSeekAbsolute?.(absolute)) {
pendingSeekRef.current = absolute
setCurrentTime(absolute)
@@ -682,10 +721,12 @@ export function PlayerControls({
setSeekHover(clampRatio((event.clientX - rect.left) / rect.width))
}
const revealControls = useCallback(() => {
// 锁住时键盘操作(方向键调进度/音量)不应该把控制栏唤回来。
if (uiLockedRef.current) return
onUiVisibleChange(true)
/**
* 方向键调进度时只续期自动隐藏计时器,不主动唤出操作栏:控制栏原本隐藏时
* 保持画面干净(只留轻量的快进/回退提示),原本可见时也不会在连续按键中途消失。
*/
const refreshControlsTimer = useCallback(() => {
if (uiLockedRef.current || !uiVisibleRef.current) return
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
const el = video()
if (!el || el.paused || holdControlsRef.current) {
@@ -723,12 +764,14 @@ export function PlayerControls({
}
pendingSeekRef.current = target
// 方向键也是一次真实 seek:播放事件在抑制窗口内不得唤出控制栏。
lastSeekActivityRef.current = Date.now()
setCurrentTime(target)
setSeekHint({
dir: applied < 0 ? 'back' : 'forward',
seconds: Math.abs(Math.round(applied)),
})
revealControls()
refreshControlsTimer()
if (seekHintTimerRef.current) clearTimeout(seekHintTimerRef.current)
seekHintTimerRef.current = setTimeout(() => setSeekHint(null), SEEK_HINT_MS)
@@ -750,7 +793,7 @@ export function PlayerControls({
if (!burst) return
applySeekRef.current(burst.base + burst.delta)
}, SEEK_APPLY_MS)
}, [revealControls, streamOffset, video])
}, [refreshControlsTimer, streamOffset, video])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
+115 -3
View File
@@ -88,6 +88,16 @@ type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message:
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
// 手动标记的提示里展示的时间点(mm:ss 或 h:mm:ss)。
function formatMarkTime(totalSec: number): string {
const safe = Math.max(0, Math.floor(totalSec))
const hours = Math.floor(safe / 3600)
const minutes = Math.floor((safe % 3600) / 60)
const seconds = safe % 60
const pad = (n: number) => String(n).padStart(2, '0')
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
@@ -129,6 +139,10 @@ export function PlayerPage() {
return () => guard.invalidate()
}, [id])
const directRetryRef = useRef(false)
// 直连 302 被网盘 CDN 按 UA 防盗链拒绝后(115 直链绑定换链时的 UA,电视
// WebView、按站点改 UA 的扩展、改写 UA 的代理都会让「页面请求」与「取流
// 请求」的 UA 不一致而 403),改用服务端同源转发(?proxy=1)再试的标记。
const directViaProxyRef = useRef(false)
const retryingDirectRef = useRef(false)
// 程序化续播 seek 后的宽限截止时间;期内直连误报不升到 HLS。
const directSeekGraceUntilRef = useRef(0)
@@ -546,6 +560,7 @@ export function PlayerPage() {
setDanmakuInfo(null)
setDanmakuSearching(true)
directRetryRef.current = false
directViaProxyRef.current = false
retryingDirectRef.current = false
directSeekGraceUntilRef.current = 0
transientPlayErrorRef.current = ''
@@ -1475,7 +1490,7 @@ export function PlayerPage() {
} else {
// VR 全景需要浏览器能读帧:STRM/网盘直链会被 302 到跨域 CDN,此时改为
// 服务端同源转发(原画,不转码);普通播放仍走 302 直连,省服务器流量。
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current })
const url = streamURL(mediaId, { proxy: vr360DirectProxyRef.current || directViaProxyRef.current })
const absoluteURL = new URL(url, window.location.href).href
// 其它异步播放器状态更新不应重启同一个直连请求;STRM 的重定向/换链
// 比本地文件慢,重启请求可能产生一个短暂但会触发 onError 的中断。
@@ -2245,6 +2260,73 @@ export function PlayerPage() {
if (activeSkip) performSkip(activeSkip)
}, [activeSkip, performSkip])
// ── 手动标记片头/片尾 ──────────────────────────────────────────────────────
// 服务端把手动标记的优先级排在最前,并按季传播到还没标记的集,所以这里只需
// 把「当前播放位置」发过去,再用返回的合并结果替换本地片段。
const manualMarkBusyRef = useRef(false)
const submitManualMark = useCallback(
(kind: 'intro' | 'credits', clear: boolean) => {
const current = mediaRef.current
if (!current?.id || manualMarkBusyRef.current) return
// 位置必须是源时间轴上的绝对秒数:本地 HLS 的 t=0 对应 hlsStartSec。
const video = ref.current
const positionSec = playbackPositionSec(
modeRef.current,
hlsSourceRef.current,
hlsStartSecRef.current,
video?.currentTime || 0,
)
const positionMs = Math.max(0, Math.round(positionSec * 1000))
if (!clear && positionMs <= 0) {
toast.error('当前位置为 0,请播放到片头结束后再标记')
return
}
manualMarkBusyRef.current = true
const markLabel = kind === 'intro' ? '片头' : '片尾'
const markTime = formatMarkTime(positionSec)
playbackAPI
.markSegment(current.id, {
kind,
position_ms: clear ? undefined : positionMs,
clear,
})
.then((res) => {
setRawSkipSegments(res.segments ?? [])
setAutoSkipIntro(Boolean(res.auto_skip))
setActiveSkip(null)
activeSkipKeyRef.current = ''
setDismissedSkipKinds([])
setAutoSuppressedKinds([])
if (clear) {
toast.success(`已清除${markLabel}标记`)
} else {
toast.success(`已标记${markLabel}:${markTime}(同季其它集已同步)`)
}
})
.catch(() => toast.error('标记保存失败,请稍后重试'))
.finally(() => {
manualMarkBusyRef.current = false
})
},
[],
)
const handleMarkIntro = useCallback(() => submitManualMark('intro', false), [submitManualMark])
const handleMarkOutro = useCallback(() => submitManualMark('credits', false), [submitManualMark])
const handleClearIntroMark = useCallback(() => submitManualMark('intro', true), [submitManualMark])
const handleClearOutroMark = useCallback(() => submitManualMark('credits', true), [submitManualMark])
// 已有手动标记的类型:用于按钮提示文案。只有手动标记可以清除,外部源的不算。
const markPresence = useMemo(() => {
let intro = false
let outro = false
for (const segment of rawSkipSegments) {
if (segment.kind === 'intro') intro = true
if (segment.kind === 'credits') outro = true
}
return { intro, outro }
}, [rawSkipSegments])
const handleUndoSkip = useCallback(() => {
const notice = skipNotice
setSkipNotice(null)
@@ -2378,7 +2460,7 @@ export function PlayerPage() {
if (retryingDirectRef.current) return
const expectedSrc = mediaId
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current }), window.location.href).href
? new URL(streamURL(mediaId, { proxy: vr360DirectProxyRef.current || directViaProxyRef.current }), window.location.href).href
: ''
const action = classifyDirectPlayError({
errorCode: video?.error?.code,
@@ -2416,8 +2498,32 @@ export function PlayerPage() {
return
}
// STRM/115 直连重试仍失败:最常见的原因是 115 防盗链按 UA 拒绝——直链
// 绑定换链(302)时浏览器的 UA,但取流请求的 UA 可能不同(电视/WebView
// 的内核播放器、按站点改 UA 的扩展、改写 UA 的代理)。这在本机无法修复,
// 而服务端同源转发(?proxy=1)由 MeBox 用绑定 UA 取流、必然通过校验。
// 先用转发续播原画;仍失败再走 scheduleDirectFallback 退转码线路。
if (
video &&
mediaId &&
!directViaProxyRef.current &&
!vr360DirectProxyRef.current &&
isStrmMedia(mediaRef.current)
) {
directViaProxyRef.current = true
clearFallbackTimer()
recordDiagnostic('error', '直连被 CDN 拒绝(多为 UA 防盗链),改用服务端转发重试')
const resumeAt = pendingDirectSeekRef.current ?? (video.currentTime > 2 ? video.currentTime : 0)
pendingDirectSeekRef.current = null
video.src = streamURL(mediaId, { proxy: true })
void video.play().catch(() => undefined)
if (resumeAt > 1) seekStrmDirectTo(resumeAt)
armDirectStallWatchdog()
return
}
scheduleDirectFallback()
}, [mediaId, mode, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure])
}, [armDirectStallWatchdog, clearFallbackTimer, mediaId, mode, recordDiagnostic, scheduleDirectFallback, seekStrmDirectTo, showPlayFailure])
const changeSubtitlePosition = useCallback((nextPosition: SubtitlePosition) => {
setSubtitlePosition(nextPosition)
@@ -2547,6 +2653,12 @@ export function PlayerPage() {
onSkipPrompt={handleSkipClick}
skipNotice={skipNotice ? { text: skipNotice.text } : null}
onUndoSkip={handleUndoSkip}
onMarkIntro={handleMarkIntro}
onMarkOutro={handleMarkOutro}
onClearIntroMark={handleClearIntroMark}
onClearOutroMark={handleClearOutroMark}
hasIntroMark={markPresence.intro}
hasOutroMark={markPresence.outro}
playlistPanel={
isMobileTheater
? undefined
+126 -1
View File
@@ -242,6 +242,15 @@ type PlayerVideoStageProps = {
/** 自动跳过后的撤销提示;null 表示不显示。 */
skipNotice?: { text: string } | null
onUndoSkip?: () => void
/** 手动标记:把当前位置记为片头结束 / 片尾开始。 */
onMarkIntro?: () => void
onMarkOutro?: () => void
/** 长按清除已有的片头 / 片尾标记。 */
onClearIntroMark?: () => void
onClearOutroMark?: () => void
/** 当前是否已有对应类型的标记(决定按钮文案与长按是否可用)。 */
hasIntroMark?: boolean
hasOutroMark?: boolean
waiting?: boolean
waitingMessage?: string
}
@@ -312,6 +321,12 @@ export function PlayerVideoStage({
onSkipPrompt,
skipNotice = null,
onUndoSkip,
onMarkIntro,
onMarkOutro,
onClearIntroMark,
onClearOutroMark,
hasIntroMark = false,
hasOutroMark = false,
waiting = false,
waitingMessage = '',
}: PlayerVideoStageProps) {
@@ -335,6 +350,39 @@ export function PlayerVideoStage({
const lockRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lockVisible = lockHovered || lockShown
const vrActive = Boolean(vr360)
// 手动标记入口:画面左边缘一条半透明小白条,常态常驻(锁画面时才收),
// 点开才展开「片头 / 片尾」——展开的动作面板跟下面进度条同进同出。
// 悬停白条时按住操作栏:它不在操作栏自身的悬停判定范围内,鼠标停在上面时
// 操作栏会照常在三秒后收起,否则展开的面板会被一起带走。
const [markBarHovered, setMarkBarHovered] = useState(false)
const [markDockOpen, setMarkDockOpen] = useState(false)
const markDockRef = useRef<HTMLDivElement>(null)
const markDockVisible = !locked
const markPanelVisible = markDockVisible && controlsVisible
// 进度条收起(或画面锁定)就关掉标记面板:展开的动作跟操作栏同一套显隐规则。
useEffect(() => {
if (!markPanelVisible) setMarkDockOpen(false)
}, [markPanelVisible])
// 点画面别处、换片或按 Esc 都收起面板,避免它挂在那里挡住画面。
useEffect(() => {
if (!markDockOpen) return
const onDocPointerDown = (event: Event) => {
const dock = markDockRef.current
if (dock && event.target instanceof Node && dock.contains(event.target)) return
setMarkDockOpen(false)
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMarkDockOpen(false)
}
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('pointerdown', onDocPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
}
}, [markDockOpen])
// VR 渲染器是否已经画出第一帧(在此之前给出「正在启动」提示,避免只看到黑屏)。
// 只按「媒体 ID」记录:切换投影方式/画幅布局不会重建画布,也就不能清掉这个
// 状态——渲染器只在挂载后上报一次就绪,清掉之后提示会一直停在「正在启动」。
@@ -552,6 +600,7 @@ export function PlayerVideoStage({
// 锁上时立刻收起操作栏;解锁时也保持收起,这样画面在任何一侧都不会突然
// 弹出一整条浮层。解锁那一下顺带显示「已解锁」提示。
setControlsVisible(false)
setMarkBarHovered(false)
revealLock(!next)
}
@@ -561,6 +610,8 @@ export function PlayerVideoStage({
setLockShown(false)
setLockHint(false)
setLockHovered(false)
setMarkBarHovered(false)
setMarkDockOpen(false)
if (lockRevealTimerRef.current) {
clearTimeout(lockRevealTimerRef.current)
lockRevealTimerRef.current = null
@@ -892,7 +943,7 @@ export function PlayerVideoStage({
onPlaybackRateChange={onPlayerPlaybackRateChange}
uiVisible={controlsVisible && !locked}
onUiVisibleChange={setControlsVisible}
uiHold={vrUiHovered}
uiHold={vrUiHovered || markBarHovered}
uiLocked={locked}
subs={subs}
subtitleIndex={subtitleIndex}
@@ -966,6 +1017,80 @@ export function PlayerVideoStage({
) : null}
{danmakuPanel}
{playlistPanel}
{/* 手动标记片头/片尾:数据源查不到时用户自己标一次,服务端会按季传播。
常态只有画面左边缘一条半透明小白条;点开才展开「片头 / 片尾」,展开的
动作跟下面进度条同进同出。全屏时也一样,静止画面里只有这条小白条。
位置避开左侧居中的锁定按钮,不与它的悬停感应区重叠。 */}
{onMarkIntro || onMarkOutro ? (
<div
ref={markDockRef}
aria-hidden={!markDockVisible}
className={`absolute left-0 top-[78%] z-30 -translate-y-1/2 select-none transition duration-200 ${
markDockVisible ? 'opacity-100' : 'pointer-events-none -translate-x-1 opacity-0'
}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onPointerEnter={(event) => {
// 只有真鼠标才按住操作栏:触摸端抬起后拿不到可靠的离开信号。
if (event.pointerType === 'mouse') setMarkBarHovered(true)
}}
onPointerLeave={() => setMarkBarHovered(false)}
>
<div className="flex items-center">
<button
type="button"
tabIndex={markDockVisible ? 0 : -1}
aria-expanded={markDockOpen}
aria-label={markDockOpen ? '收起片头片尾标记' : '标记片头 / 片尾'}
title={markDockOpen ? '收起' : '标记片头 / 片尾'}
onClick={() => {
// 展开的动作面板跟进度条走:操作栏收着时先把它亮出来。
if (!markDockOpen) setControlsVisible(true)
setMarkDockOpen((open) => !open)
}}
className="group flex h-12 items-center bg-transparent pl-0 pr-3 focus-visible:outline-none"
>
<span
className={`block h-10 rounded-r-full bg-white/40 shadow-[0_0_8px_rgba(0,0,0,0.55)] transition-all duration-200 group-hover:w-1.5 group-hover:bg-white/70 group-focus-visible:w-1.5 group-focus-visible:bg-white/90 ${
markDockOpen ? 'w-1.5 bg-white/70' : 'w-1'
}`}
/>
</button>
{markDockOpen && markPanelVisible ? (
<div className="flex items-center gap-0.5 rounded-full border border-white/10 bg-black/70 py-0.5 pl-2 pr-0.5 text-[11px] text-white shadow-[0_6px_24px_rgba(0,0,0,0.45)] backdrop-blur-md">
<span className="pr-0.5 text-[10px] tracking-wide text-white/40">标记</span>
<button
type="button"
onClick={() => onMarkIntro?.()}
onContextMenu={(event) => {
if (!hasIntroMark) return
event.preventDefault()
onClearIntroMark?.()
}}
title={hasIntroMark ? '点击重新标记片头,右键清除' : '把当前位置标为片头结束'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片头
</button>
<button
type="button"
onClick={() => onMarkOutro?.()}
onContextMenu={(event) => {
if (!hasOutroMark) return
event.preventDefault()
onClearOutroMark?.()
}}
title={hasOutroMark ? '点击重新标记片尾,右键清除' : '把当前位置标为片尾开始'}
className="rounded-full px-2.5 py-1 font-medium leading-4 text-white/75 transition hover:bg-white/15 hover:text-white"
>
片尾
</button>
</div>
) : null}
</div>
</div>
) : null}
</>
) : loadError ? (
<p className="max-w-[92%] text-center text-sm text-rose-400">{loadError}</p>
+15 -2
View File
@@ -23,11 +23,24 @@ export const generalSettingsGroup: SettingGroup = {
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
defaultValue: 'false',
},
{
key: 'segment.source',
label: '片头/片尾数据源',
type: 'select',
hint: '选择查询哪个社区库来补全片头/片尾时间点。TheIntroDB 数据最全(按 TMDb id);IntroDB 只认 IMDb id,需要 TMDb 已配置 API key 才能解析;TODb 偏华语片源、按平台版本记录。选「关闭」后只使用本地已有数据(含手动标记),不再请求外网。',
defaultValue: 'theintrodb',
options: [
{ value: 'theintrodb', label: 'TheIntroDB' },
{ value: 'introdb', label: 'IntroDB' },
{ value: 'todb', label: 'TODb(TheOtherDB)' },
{ value: 'off', label: '关闭' },
],
},
{
key: 'segment.prewarm_enabled',
label: '后台预热 IntroDB 片头片段',
label: '后台预热片头片段',
type: 'toggle',
hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
hint: '默认关闭。开启后每 6 小时在后台向上面选择的数据源拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。',
defaultValue: 'false',
},
{
+12 -1
View File
@@ -15,7 +15,7 @@ function equal<T>(name: string, actual: T, expected: T) {
check('空片段返回空数组', toSkipSegments(null, 600).length === 0)
check('空数组返回空数组', toSkipSegments([], 600).length === 0)
// end_ms: 0 表示延续到片尾,用媒体时长补齐。
// end_ms: 0 只在片尾类(credits/preview)表示「延续到片尾」,用媒体时长补齐。
const resolved = toSkipSegments(
[
{ kind: 'intro', start_ms: 228_664, end_ms: 246_143 },
@@ -28,6 +28,17 @@ equal('片头起点(秒)', resolved[0].startSec, 228.664)
equal('片头终点(秒)', resolved[0].endSec, 246.143)
equal('片尾终点补齐为总时长', resolved[1].endSec, 3600)
// 片头 / 回顾的 end_ms: 0 是数据源的「这段不存在」占位(TheIntroDB 官方语义),
// 绝不能补齐成总时长——那会让整集都落在片头区间里,一开播就弹跳过按钮。
check(
'片头 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'intro', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
check(
'回顾 end_ms=0 不得补齐为整集',
toSkipSegments([{ kind: 'recap', start_ms: 0, end_ms: 0 }], 3600).length === 0,
)
// 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。
check(
'时长未知时丢弃开放区间',
+15 -5
View File
@@ -33,8 +33,11 @@ export function skippedNoticeText(kind: PlaybackSegmentKind): string {
/**
* 把服务端返回的片段换算成源时间轴上的秒区间。
*
* end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值)
* 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
* end_ms === 0 是「一直到片尾」的哨兵值,只有片尾类(credits / preview)会这么
* 返回,用媒体总时长补齐。片头 / 回顾出现 0 一律丢弃:那通常意味着数据源根本没
* 给出结束点(TheIntroDB 用 end_ms: 0 表示「这段不存在」),补成总时长会让整集
* 都落在「片头」区间里,一开播就弹跳过按钮。时长未知(0 / 非有限值)同样丢弃,
* 否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
*/
export function toSkipSegments(
segments: PlaybackSegment[] | null | undefined,
@@ -44,9 +47,16 @@ export function toSkipSegments(
const out: SkipSegment[] = []
for (const segment of segments) {
const startSec = Math.max(0, segment.start_ms / 1000)
const endSec = segment.end_ms > 0 ? segment.end_ms / 1000 : durationSec
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
const openEnded = segment.kind === 'credits' || segment.kind === 'preview'
if (segment.end_ms > 0) {
const endSec = segment.end_ms / 1000
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
continue
}
if (!openEnded) continue
if (!Number.isFinite(durationSec) || durationSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec: durationSec })
}
return out.sort((a, b) => a.startSec - b.startSec)
}