mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d91ded9d55 | |||
| 8458b8060a | |||
| 623d87f12b | |||
| d820214abc | |||
| 0ab4693b92 | |||
| db516ded61 | |||
| 416d4c8c3b | |||
| 78cfe0aba2 |
@@ -45,7 +45,7 @@ func setDefaults(v *viper.Viper) {
|
||||
v.SetDefault("logging.max_backups", 10)
|
||||
|
||||
v.SetDefault("cache.cache_dir", "./cache")
|
||||
v.SetDefault("cache.images_max_size_mb", 2000)
|
||||
v.SetDefault("cache.images_max_size_mb", 2048)
|
||||
v.SetDefault("cache.images_originals_max_size_mb", 256)
|
||||
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
|
||||
v.SetDefault("cache.redis_url", "")
|
||||
|
||||
@@ -120,8 +120,13 @@ type LoggingConfig struct {
|
||||
|
||||
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
|
||||
type CacheConfig struct {
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
CacheDir string `mapstructure:"cache_dir"`
|
||||
// ImagesMaxSizeMB 是整个磁盘缓存的总容量上限(MB),不是 images 子目录
|
||||
// 的配额。参与统计的池为 hls、images、library-covers;超限时按该顺序淘汰
|
||||
// 最旧文件。reader-audio 不在其中:它按 reader.audioMediaMaxBytes 的固定
|
||||
// 配额(当前 1GiB)在写入时独立淘汰,不参与这里的总量。
|
||||
// 0 表示不限制。
|
||||
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
|
||||
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
|
||||
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
|
||||
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
|
||||
|
||||
@@ -89,7 +89,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
|
||||
if svc.Scheduler != nil {
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
|
||||
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "cache_cleanup")
|
||||
}
|
||||
}
|
||||
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
|
||||
|
||||
@@ -0,0 +1,764 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderMediaProxyStopsResendingRejectedTicket 上游成批拒绝时(连新签的票
|
||||
// 也是空 401),不能在播放器那一次请求里把同一张被拒的票打上十几遍:连续两轮
|
||||
// 换票都是同一个状态码就立刻失败,把明确文案交出去。
|
||||
func TestReaderMediaProxyStopsResendingRejectedTicket(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
|
||||
signed := primeTicketBook(t, svc, book.ID)
|
||||
srv.denyAlways()
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("上游持续拒绝应返回 502,实际 %d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "401") {
|
||||
t.Fatalf("错误文案应带上上游状态码: %s", body)
|
||||
}
|
||||
// 每轮 fetchReaderMedia = 首拉(401) + 换票后重拉(401),两轮即收手。
|
||||
if got := srv.mediaRequests(); got > 4 {
|
||||
t.Fatalf("无进展的拒绝应当两轮就收手,实际上游请求 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioCacheResumesTruncatedUpstream 上游中途 RST 后用 Range 续传:
|
||||
// 播放器最终拿到的仍是完整的一章,而不是从头重下。
|
||||
func TestReaderAudioCacheResumesTruncatedUpstream(t *testing.T) {
|
||||
const bodyBytes = 300 << 10
|
||||
payload := bytes.Repeat([]byte("m4a!"), bodyBytes/4)
|
||||
var ranges []string
|
||||
var mu sync.Mutex
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
ranges = append(ranges, r.Header.Get("Range"))
|
||||
count := len(ranges)
|
||||
mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mp4")
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
start := 0
|
||||
if count > 1 {
|
||||
// 第二次起按 Range 续传。
|
||||
start = bodyBytes / 2
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, bodyBytes-1, bodyBytes))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
} else {
|
||||
w.Header().Set("Content-Length", strconv.Itoa(bodyBytes))
|
||||
}
|
||||
chunk := payload[start:]
|
||||
if count == 1 {
|
||||
// 第一次只写一半就断开。
|
||||
chunk = payload[:bodyBytes/2]
|
||||
}
|
||||
for written := 0; written < len(chunk); written += 8 << 10 {
|
||||
end := written + 8<<10
|
||||
if end > len(chunk) {
|
||||
end = len(chunk)
|
||||
}
|
||||
if _, err := w.Write(chunk[written:end]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
if count == 1 {
|
||||
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")
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("续传后应当成功,实际 status=%d body=%.120s", code, body)
|
||||
}
|
||||
if len(body) != bodyBytes {
|
||||
t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(ranges) < 2 || ranges[0] != "" {
|
||||
t.Fatalf("请求序列 = %q,期望先整段再续传", ranges)
|
||||
}
|
||||
if !strings.HasPrefix(ranges[1], "bytes=") {
|
||||
t.Fatalf("第二轮应当带 Range,实际 %q", ranges[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 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),根因被彻底掩盖。
|
||||
//
|
||||
// 音轨走的是整段落盘缓存(见 TestReaderAudioCacheRejectsTruncatedUpstream):
|
||||
// 拉不完整就重试,全部失败回 502,绝不会把半截音频交给播放器。
|
||||
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)
|
||||
// .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。
|
||||
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4")
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioCacheServesFromDisk 音轨首次拉取后落盘;后续请求即使带着一张
|
||||
// 新签的票(query 全变了)也不再回源——这是让播放不受跨境链路抖动影响的关键。
|
||||
func TestReaderAudioCacheServesFromDisk(t *testing.T) {
|
||||
svc, srv, book := prepareMediaTicketBook(t)
|
||||
router := mediaRouterForTest(svc)
|
||||
signed := primeTicketBook(t, svc, book.ID)
|
||||
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
|
||||
t.Fatalf("首次拉取失败 status=%d body=%q", code, body)
|
||||
}
|
||||
if got := srv.mediaRequests(); got != 1 {
|
||||
t.Fatalf("首次拉取应对上游请求 1 次,实际 %d", got)
|
||||
}
|
||||
|
||||
// 同一段音频的另一张票:URL 的 query 不同,缓存键必须一致。
|
||||
rotated := svc.Reader.ProxyURL(book.ID, srv.URL+"/media?t=ticket-rotated")
|
||||
code, body = getReaderMedia(t, router, rotated)
|
||||
if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") {
|
||||
t.Fatalf("命中缓存失败 status=%d body=%q", code, body)
|
||||
}
|
||||
if got := srv.mediaRequests(); got != 1 {
|
||||
t.Fatalf("命中缓存后不该再回源,实际上游请求 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReaderAudioCacheAbortsOnTruncatedUpstream 首播直通模式下上游持续截断的契约:
|
||||
//
|
||||
// 冷启动走边下边发(见 handleStreamingReaderAudio),响应头和前缀字节在探测
|
||||
// 通过后就已发出,因此中途截断不可能再改写成 502 —— 必须中止连接让客户端
|
||||
// 判定「传输不完整」,同时保留 .part 分片。
|
||||
//
|
||||
// 关键不变量:**绝不能留下可命中的完整缓存**(否则后续会把截断音频当真完整
|
||||
// 文件下发,播放器表现为「时长正确但没有声音」)。下一次请求带着分片走续传
|
||||
// 路径,那时才可能给出 502 + 明确文案。
|
||||
func TestReaderAudioCacheAbortsOnTruncatedUpstream(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 下会向上冒泡,
|
||||
// 这里断言它确实发生了(即「没有静默地把半截音频当成功」)。
|
||||
func() {
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatalf("上游截断时必须中止响应,实际正常返回了 %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)")
|
||||
}
|
||||
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
|
||||
t.Fatalf("中断的下载不能留下可命中的完整缓存")
|
||||
}
|
||||
if !svc.Reader.HasPartialRemoteAudio(book.ID, srv.URL+"/media") {
|
||||
t.Fatalf("中断后应保留 .part 分片供续传")
|
||||
}
|
||||
|
||||
// 二次请求带分片走续传路径;上游依旧忽略 Range 并截断 → 这时才给 502。
|
||||
code, body := getReaderMedia(t, router, signed)
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("续传路径持续失败应返回 502,实际 %d body=%.120s", code, body)
|
||||
}
|
||||
if strings.Contains(body, "ftyp") {
|
||||
t.Fatalf("不能把半截音频交给客户端: %q", body)
|
||||
}
|
||||
if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok {
|
||||
t.Fatalf("失败的续传仍不应留下完整缓存")
|
||||
}
|
||||
}
|
||||
|
||||
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, reader.ContentOptions{})
|
||||
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
|
||||
mediaHits int
|
||||
noToken bool
|
||||
errorPage bool
|
||||
alwaysDeny 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":
|
||||
s.mu.Lock()
|
||||
s.mediaHits++
|
||||
deny := s.alwaysDeny
|
||||
s.mu.Unlock()
|
||||
if deny {
|
||||
// 与腾讯 CDN 一致:连新签的票也直接空 401(成批拒绝的那几分钟)。
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
// denyAlways 让媒体端点连新签的票也一律空 401(模拟上游成批拒绝的那几分钟)。
|
||||
func (s *mediaTicketServer) denyAlways() {
|
||||
s.mu.Lock()
|
||||
s.alwaysDeny = 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
|
||||
}
|
||||
|
||||
func (s *mediaTicketServer) mediaRequests() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.mediaHits
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// TestReaderAudioStreamsFirstByteBeforeDownloadCompletes 首播 TTFB 回归:
|
||||
// 冷启动必须边下边发,而不是等整章下完再一次性下发。
|
||||
//
|
||||
// 上游在第一块之后卡住不放:直通实现此时应已把那块转给客户端,所以读端能
|
||||
// 拿到字节;旧实现(整段下载后才 ServeContent)在 release 关闭前一个字节都
|
||||
// 不会发出,读端会一直阻塞。
|
||||
func TestReaderAudioStreamsFirstByteBeforeDownloadCompletes(t *testing.T) {
|
||||
// 总长必须大于探测窗口(readerMediaProbeBytes=64KB):首播要先读完这一小段
|
||||
// 才能判断上游给的是错误页还是媒体,这之后才可能外发。卡点在探测结束之后,
|
||||
// 才能证明「整段下完才发」和「边下边发」的区别。
|
||||
const chunks = 16
|
||||
const chunkBytes = 16 << 10
|
||||
const stallAfter = 4 // 写满探测窗口(4×16KB=64KB)后卡住
|
||||
release := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
|
||||
upstream := 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(chunks*chunkBytes))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
for i := 0; i < chunks; i++ {
|
||||
if _, err := w.Write(bytes.Repeat([]byte("m4a!"), chunkBytes/4)); err != nil {
|
||||
return
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
if i+1 == stallAfter {
|
||||
// 探测窗口刚写满就卡住,直到测试确认客户端已经收到字节。
|
||||
<-release
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc, _, book := prepareMediaTicketBookWithUpstream(t, upstream.URL)
|
||||
signed := svc.Reader.ProxyURL(book.ID, upstream.URL+"/media")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/api/reader/media", readerMediaProxyHandler(svc))
|
||||
proxy := httptest.NewServer(router)
|
||||
defer proxy.Close()
|
||||
|
||||
resp, err := http.Get(proxy.URL + signed)
|
||||
if err != nil {
|
||||
t.Fatalf("请求媒体代理失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("直通应返回 200,实际 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 上游仍卡在 release 上:此时能读到第一批字节,才说明是边下边发。
|
||||
first := make([]byte, 4)
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, readErr := io.ReadFull(resp.Body, first)
|
||||
readDone <- readErr
|
||||
}()
|
||||
select {
|
||||
case readErr := <-readDone:
|
||||
if readErr != nil {
|
||||
t.Fatalf("读取首批字节失败: %v", readErr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
unblock()
|
||||
t.Fatalf("首播没有边下边发:上游卡在中途(仍有 %d 字节未发)时客户端读不到任何字节",
|
||||
(chunks-stallAfter)*chunkBytes)
|
||||
}
|
||||
if string(first) != "m4a!" {
|
||||
t.Fatalf("首批字节内容不对: %q", first)
|
||||
}
|
||||
|
||||
// 放行上游,把剩余字节读完并确认缓存落盘完整。
|
||||
unblock()
|
||||
rest, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("读取剩余字节失败: %v", readErr)
|
||||
}
|
||||
if total := 4 + len(rest); total != chunks*chunkBytes {
|
||||
t.Fatalf("交付字节 = %d,期望 %d", total, chunks*chunkBytes)
|
||||
}
|
||||
// 客户端读完最后一个字节时,服务端可能还在做 truncate + rename:
|
||||
// 落盘完成是 handler 的收尾动作,给一个有限窗口等它。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !svc.Reader.HasCachedRemoteAudio(book.ID, upstream.URL+"/media") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("直通完成后应命中缓存")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
@@ -122,16 +132,27 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
// MediaDirect 媒体直连开关:允许客户端自行下载音频/漫画图片字节。
|
||||
// 可单独提交,也可与其他字段一起提交(只更新出现的字段)。
|
||||
MediaDirect *bool `json:"media_direct"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Enabled == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
|
||||
if err := c.ShouldBindJSON(&body); err != nil || (body.Enabled == nil && body.MediaDirect == nil) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 或 media_direct 字段必填"})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
if body.Enabled != nil {
|
||||
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.MediaDirect != nil {
|
||||
if err := svc.Reader.SetSourceMediaDirect(c.Request.Context(), userID, c.Param("id"), *body.MediaDirect); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -403,6 +424,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
|
||||
if info != nil {
|
||||
// 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
|
||||
// 地址写回,再做下发时的解密代理改写。
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
|
||||
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
@@ -869,12 +894,34 @@ 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)
|
||||
|
||||
// 远端音轨:整段落盘缓存,命中后不再回源。
|
||||
//
|
||||
// 听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回空 401
|
||||
// 或直接 RST 连接。旧实现每次都把抖动原样交给播放器:要么是 502 的
|
||||
// JSON,要么是截断的 206,浏览器一律判成 MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
// (前端据此提示「格式不支持,请检查 ffmpeg」——其实完全无关)。
|
||||
// 缓存后只要首播成功一次,后续拖动进度、续播、重试都走本地文件。
|
||||
// 缓存目录不可用时退回旧的流式透传,不让配置问题直接变成「播不了」。
|
||||
if c.Request.Method == http.MethodGet &&
|
||||
reader.IsAudioMediaURL(rawURL, book.Type) &&
|
||||
svc.Reader.RemoteAudioCachePath(bookID, rawURL) != "" {
|
||||
handleCachedReaderAudio(c, svc, book, bookID, rawURL, mediaOptions.Headers)
|
||||
return
|
||||
}
|
||||
|
||||
// 只有 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 +932,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 +945,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 +970,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 +980,628 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 远端音轨落盘缓存 ──────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
// readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。
|
||||
// 续传只补没下完的部分,所以这里的次数比过去更可用:每次失败都能推进进度。
|
||||
readerAudioCacheAttempts = 4
|
||||
// readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的,
|
||||
// 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。
|
||||
readerMediaReissueAttempts = 3
|
||||
readerMediaReissueDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。
|
||||
var readerAudioFetchGroup singleflight.Group
|
||||
|
||||
// readerAudioStreamLocks 首播直通按缓存键串行化。
|
||||
//
|
||||
// 直通模式下两个并发请求会同时写同一个 .part 文件(播放器 + 预热器各自开一条
|
||||
// 连接很常见),交错写入必然把文件写坏。这里让后到者排队等前一个下完,
|
||||
// 拿到锁后再查一次缓存直接下发——语义与原来 singleflight 等价,只是领导者
|
||||
// 是在流式外发的,追随者不需要重复下载。
|
||||
var readerAudioStreamLocks = newKeyedMutex()
|
||||
|
||||
// keyedMutex 按字符串键串行化,锁释放后回收条目,避免 map 无限增长。
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
locks map[string]*keyedMutexEntry
|
||||
}
|
||||
|
||||
type keyedMutexEntry struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedMutex() *keyedMutex {
|
||||
return &keyedMutex{locks: map[string]*keyedMutexEntry{}}
|
||||
}
|
||||
|
||||
// Lock 取得该键的独占锁,返回解锁函数(必须调用)。
|
||||
func (k *keyedMutex) Lock(key string) func() {
|
||||
k.mu.Lock()
|
||||
entry := k.locks[key]
|
||||
if entry == nil {
|
||||
entry = &keyedMutexEntry{}
|
||||
k.locks[key] = entry
|
||||
}
|
||||
entry.refs++
|
||||
k.mu.Unlock()
|
||||
|
||||
entry.mu.Lock()
|
||||
return func() {
|
||||
entry.mu.Unlock()
|
||||
k.mu.Lock()
|
||||
entry.refs--
|
||||
if entry.refs == 0 {
|
||||
delete(k.locks, key)
|
||||
}
|
||||
k.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// handleCachedReaderAudio 音频直链的统一入口。
|
||||
//
|
||||
// 三条路径,按代价从低到高:
|
||||
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
|
||||
// 2. 冷启动首播 → 边下边发给客户端,同时落盘(DownloadRemoteAudioStreaming)。
|
||||
// 旧实现是「整段下完再下发」,慢链路上用户要对着转圈等整章 3–5MB,
|
||||
// 把「偶发失败」换成了「必然慢」;直通后几百毫秒就能出声,落盘照旧;
|
||||
// 3. 已有 .part 分片 → 走整段下载:分片意味着上次已经下到一半,同步续传
|
||||
// 剩余字节比重新直通更省流量,下完再一次性下发。
|
||||
func handleCachedReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) {
|
||||
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
|
||||
defer f.Close()
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
|
||||
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
|
||||
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
|
||||
canStream := c.Request.Method == http.MethodGet &&
|
||||
rangeHeader == "" &&
|
||||
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
|
||||
|
||||
if canStream {
|
||||
handleStreamingReaderAudio(c, svc, book, bookID, rawURL, headers)
|
||||
return
|
||||
}
|
||||
|
||||
cachePath, err := ensureCachedRemoteAudio(c.Request.Context(), svc, book, bookID, rawURL, headers)
|
||||
if err != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频拉取失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
f, err := os.Open(cachePath) // #nosec G304 -- 路径由服务端哈希生成
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error())
|
||||
return
|
||||
}
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
}
|
||||
|
||||
// handleStreamingReaderAudio 首播直通:一边落盘一边把字节写给客户端。
|
||||
//
|
||||
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
|
||||
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
|
||||
// 不能改成 502 —— 状态码早就发出去了。
|
||||
func handleStreamingReaderAudio(
|
||||
c *gin.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) {
|
||||
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
|
||||
if key == "" {
|
||||
c.String(http.StatusBadGateway, "音频缓存不可用")
|
||||
return
|
||||
}
|
||||
unlock := readerAudioStreamLocks.Lock(key)
|
||||
defer unlock()
|
||||
|
||||
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
|
||||
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
|
||||
defer f.Close()
|
||||
serveCachedReaderAudio(c, f, info, rawURL)
|
||||
return
|
||||
}
|
||||
|
||||
writer := c.Writer
|
||||
// started 表示响应头是否已发出。没发出时失败还能改成 502;已经发出就只能
|
||||
// 中止连接——状态码收不回来了。
|
||||
started := false
|
||||
_, err := svc.Reader.DownloadRemoteAudioStreaming(
|
||||
c.Request.Context(), bookID, rawURL, readerAudioCacheAttempts,
|
||||
readerAudioFetchFunc(c.Request.Context(), svc, book, bookID, rawURL, headers),
|
||||
func(total int64, upstreamType string) {
|
||||
contentType := readerAudioContentType(rawURL)
|
||||
// 上游给了明确类型时优先用它(个别源把 m4a 标成 audio/x-m4a)。
|
||||
if strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
|
||||
contentType = upstreamType
|
||||
}
|
||||
writer.Header().Set("Content-Type", contentType)
|
||||
writer.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
if total > 0 {
|
||||
writer.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
||||
}
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
started = true
|
||||
// 立刻把已写好的头刷给客户端:gin 的 ResponseWriter 有缓冲,
|
||||
// 不 Flush 的话播放器要等到第一批音频字节才看到响应,白等一轮 RTT。
|
||||
writer.Flush()
|
||||
},
|
||||
writer,
|
||||
)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("reader 音频直通失败", zap.String("book", bookID), zap.Error(err))
|
||||
}
|
||||
if !started {
|
||||
// 一个字节都还没发出去(首拉就失败):保持与旧实现一致的错误语义。
|
||||
c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error())
|
||||
return
|
||||
}
|
||||
// 响应头已经发出:只能中止连接,让客户端判定「传输不完整」并自己重试。
|
||||
// 保留 .part 分片,下一次请求会走续传路径。
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
|
||||
// readerAudioFetchFunc 把「带换票重试的媒体拉取」适配成下载器需要的 fetcher。
|
||||
func readerAudioFetchFunc(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) reader.AudioRangeFetcher {
|
||||
return func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
// 只有整段请求才需要嗅探响应体(判 HLS/错误页);续传拿到的是 206
|
||||
// 二进制分片,读了还得再补写一次,没必要。
|
||||
out, fetchErr := fetchReaderMediaResilient(ctx, svc, book, rawURL, rangeHeader, headers, rangeHeader == "")
|
||||
if fetchErr != nil {
|
||||
if isReaderMediaNoRetry(fetchErr) {
|
||||
return nil, nil, &reader.NoRetryError{Err: fetchErr}
|
||||
}
|
||||
return nil, nil, fetchErr
|
||||
}
|
||||
return out.resp, out.head, nil
|
||||
}
|
||||
}
|
||||
|
||||
// serveCachedReaderAudio 用 http.ServeContent 下发本地音轨(自带 Range / 206 语义)。
|
||||
func serveCachedReaderAudio(c *gin.Context, f *os.File, info os.FileInfo, rawURL string) {
|
||||
c.Header("Content-Type", readerAudioContentType(rawURL))
|
||||
c.Header("Cache-Control", "private, max-age=604800")
|
||||
http.ServeContent(c.Writer, c.Request, "audio"+readerAudioExt(rawURL), info.ModTime(), f)
|
||||
}
|
||||
|
||||
// ensureCachedRemoteAudio 单飞下载:并发命中同一章时只回源一次。
|
||||
func ensureCachedRemoteAudio(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) (string, error) {
|
||||
key := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("音频缓存目录不可用")
|
||||
}
|
||||
v, err, _ := readerAudioFetchGroup.Do(key, func() (any, error) {
|
||||
if svc.Reader.HasCachedRemoteAudio(bookID, rawURL) {
|
||||
return key, nil
|
||||
}
|
||||
if err := downloadReaderAudioToCache(ctx, svc, book, bookID, rawURL, headers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path, _ := v.(string)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("音频缓存不可用")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// downloadReaderAudioToCache 整段拉取并落盘,支持断点续传:
|
||||
// 上游 RST/超时后用 Range 从已写入的位置接着下,不再整段重下;
|
||||
// 只有字节数与上游声明的总长一致时才会生成正式缓存文件。
|
||||
func downloadReaderAudioToCache(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
bookID, rawURL string,
|
||||
headers map[string]string,
|
||||
) error {
|
||||
_, err := svc.Reader.DownloadRemoteAudio(ctx, bookID, rawURL, readerAudioCacheAttempts,
|
||||
readerAudioFetchFunc(ctx, svc, book, bookID, rawURL, headers))
|
||||
return err
|
||||
}
|
||||
|
||||
// isReaderMediaNoRetry 判断这次失败是否「换个花样重试也没用」。
|
||||
// 用于让外层下载循环立刻收手,而不是把同一张被拒的票再打四遍。
|
||||
func isReaderMediaNoRetry(err error) bool {
|
||||
if errors.Is(err, errReaderMediaStaleTicket) {
|
||||
return true
|
||||
}
|
||||
var rejected *readerMediaRejectedError
|
||||
return errors.As(err, &rejected)
|
||||
}
|
||||
|
||||
// readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。
|
||||
func readerAudioExt(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(path.Ext(u.Path))
|
||||
}
|
||||
|
||||
// readerAudioContentType 按后缀给出 Content-Type:缓存文件统一存成 .audio,
|
||||
// 不能依赖 http.ServeContent 的嗅探,直接在响应头里写死。
|
||||
func readerAudioContentType(rawURL string) string {
|
||||
switch readerAudioExt(rawURL) {
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".aac":
|
||||
return "audio/aac"
|
||||
case ".flac":
|
||||
return "audio/flac"
|
||||
case ".wav":
|
||||
return "audio/wav"
|
||||
case ".ogg", ".oga", ".opus":
|
||||
return "audio/ogg"
|
||||
default:
|
||||
// .m4a / .m4b / .wma / .ape 以及无后缀地址:腾讯的听书直链都是 m4a。
|
||||
return "audio/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
// 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 等)→ 原样透传上游状态,
|
||||
// 不改变原有行为;
|
||||
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
|
||||
// 而不是让播放器对着必然失败的地址反复重试。
|
||||
//
|
||||
// readerMediaRejectedError 上游拒绝了这张票,继续走同一条重试路径不会有进展。
|
||||
//
|
||||
// status —— 上游状态码(401/403/410)
|
||||
// refreshed —— 这次拒绝发生在「已经换过一张新票」之后
|
||||
// message —— 对外文案(留空时按状态码生成)
|
||||
//
|
||||
// 上游的拒绝常常成批出现(几分钟一波,随后自己恢复):换票拿到的是新票,但
|
||||
// 同一时刻仍被同一个状态码挡回。连续两轮都这样时立刻停止重试——继续重试只会
|
||||
// 让播放器多等十几秒,结果并不会更好。
|
||||
type readerMediaRejectedError struct {
|
||||
status int
|
||||
refreshed bool
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *readerMediaRejectedError) Error() string {
|
||||
if e.message != "" {
|
||||
return e.message
|
||||
}
|
||||
return fmt.Sprintf("音源暂时被上游拒绝(HTTP %d),请稍后重试或换源", e.status)
|
||||
}
|
||||
|
||||
// errReaderMediaStaleTicket 换票没有拿到新票(书源还是给回同一张、或干脆给不出),
|
||||
// 再重试同一个书源不会有不同结果。具体原因由外层用 %w 串上。
|
||||
var errReaderMediaStaleTicket = errors.New("音源签名失效且重新取票失败")
|
||||
|
||||
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, &readerMediaRejectedError{
|
||||
status: http.StatusUnauthorized,
|
||||
message: "音源签名失效(上游 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: %v", errReaderMediaStaleTicket, 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, &readerMediaRejectedError{
|
||||
status: retried.resp.StatusCode,
|
||||
refreshed: true,
|
||||
message: "音源换票后仍返回非媒体内容,请稍后重试或换源",
|
||||
}
|
||||
}
|
||||
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
|
||||
status := retried.resp.StatusCode
|
||||
_ = retried.resp.Body.Close()
|
||||
return nil, &readerMediaRejectedError{status: status, refreshed: true}
|
||||
}
|
||||
return retried, nil
|
||||
}
|
||||
|
||||
// fetchReaderMediaResilient 在 fetchReaderMedia(已含一次换票)之上再包一层。
|
||||
//
|
||||
// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次,
|
||||
// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮,
|
||||
// 每轮都会作废章节缓存、重新回源签票。
|
||||
//
|
||||
// 但"没有进展"的失败会提前收手,否则用户要对着转圈干等三十多秒:
|
||||
// - 换票拿不到新票(errReaderMediaStaleTicket)→ 立即结束;
|
||||
// - 换过票仍是同一个拒绝状态码 → 两轮即结束(见 readerMediaRejectedError)。
|
||||
func fetchReaderMediaResilient(
|
||||
ctx context.Context,
|
||||
svc *service.Container,
|
||||
book *model.ReaderBook,
|
||||
rawURL, rangeHeader string,
|
||||
options map[string]string,
|
||||
probeBody bool,
|
||||
) (*readerMediaResult, error) {
|
||||
var lastErr error
|
||||
lastRejected := 0
|
||||
for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(readerMediaReissueDelay * time.Duration(attempt-1)):
|
||||
}
|
||||
}
|
||||
out, err := fetchReaderMedia(ctx, svc, book, rawURL, rangeHeader, options, probeBody)
|
||||
if err == nil {
|
||||
if out == nil || out.resp == nil {
|
||||
lastErr = fmt.Errorf("音源响应为空")
|
||||
continue
|
||||
}
|
||||
if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) {
|
||||
return out, nil
|
||||
}
|
||||
status := out.resp.StatusCode
|
||||
_ = out.resp.Body.Close()
|
||||
lastErr = &readerMediaRejectedError{status: status, refreshed: true}
|
||||
} else {
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
}
|
||||
if errors.Is(err, errReaderMediaStaleTicket) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var rejected *readerMediaRejectedError
|
||||
if errors.As(lastErr, &rejected) {
|
||||
// 没有换票空间的拒绝:原样重试同一张票没有意义。
|
||||
if !rejected.refreshed {
|
||||
return nil, lastErr
|
||||
}
|
||||
// 换过票还是同一个状态码:上游这几分钟就是不放行。
|
||||
if lastRejected == rejected.status {
|
||||
return nil, lastErr
|
||||
}
|
||||
lastRejected = rejected.status
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1022,7 +1686,28 @@ func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerContentOptions 从查询参数解析正文下发的可选项。
|
||||
//
|
||||
// 目前只有 `media` 一个维度:
|
||||
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
|
||||
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
|
||||
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路。
|
||||
func readerContentOptions(c *gin.Context) reader.ContentOptions {
|
||||
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
|
||||
case "direct":
|
||||
return reader.ContentOptions{MediaDirect: true}
|
||||
case "proxy":
|
||||
return reader.ContentOptions{ForceProxy: true}
|
||||
default:
|
||||
return reader.ContentOptions{}
|
||||
}
|
||||
}
|
||||
|
||||
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
||||
//
|
||||
// media=direct 是客户端(Android App)专用的可选能力:媒体地址保留上游原始
|
||||
// 地址并由客户端自行下载,服务端只下发所需请求头。不传该参数(网页端)时
|
||||
// 行为与历史版本完全一致。
|
||||
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
chapter, err := strconv.Atoi(c.DefaultQuery("chapter", "0"))
|
||||
@@ -1031,7 +1716,8 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter)
|
||||
opts := readerContentOptions(c)
|
||||
content, err := svc.Reader.GetContentForBook(c.Request.Context(), userID, c.Param("id"), chapter, opts)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -1044,13 +1730,14 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
//
|
||||
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
|
||||
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
|
||||
// 媒体通道同样按 media=direct 查询参数选择,默认(网页端)走服务端代理。
|
||||
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
|
||||
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
|
||||
ApplyReplace bool `json:"apply_replace"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
|
||||
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
|
||||
ApplyReplace bool `json:"apply_replace"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
|
||||
return
|
||||
@@ -1060,7 +1747,7 @@ func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
|
||||
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace, readerContentOptions(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
+43
-4
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ type ReaderBookSource struct {
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
// MediaDirect 允许客户端直连上游取实体媒体(音频/漫画图片),服务端只负责
|
||||
// 解析地址与下发所需请求头。
|
||||
//
|
||||
// 只在客户端显式请求直连时生效(见 ContentOptions):网页端不声明该能力,
|
||||
// 行为与开启此字段前完全一致。
|
||||
MediaDirect bool `gorm:"default:false" json:"media_direct"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
|
||||
@@ -364,3 +364,199 @@ func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool
|
||||
}
|
||||
return []ImageCachePool{originals, derived}
|
||||
}
|
||||
|
||||
// CacheCleanupResult 是全缓存容量淘汰的统计。
|
||||
type CacheCleanupResult struct {
|
||||
TotalFilesBefore int
|
||||
TotalBytesBefore int64
|
||||
DeletedFiles int
|
||||
FreedBytes int64
|
||||
RemainingBytes int64
|
||||
// FreedByPool 记录每个池释放的字节数,方便日志定位到底是哪类缓存占满了。
|
||||
FreedByPool map[string]int64
|
||||
}
|
||||
|
||||
// cacheEvictionEntry 是全局容量淘汰里的一个候选文件。
|
||||
type cacheEvictionEntry struct {
|
||||
path string
|
||||
pool string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// cacheEvictionPoolOrder 是全局容量清理的固定优先级。
|
||||
// 顺序即“先清谁”:超限时前面的池会被整池清空,才会轮到后面的池。
|
||||
// reader-audio 不在其中:它由自身固定配额(当前 1GiB,见 reader 包的
|
||||
// audioMediaMaxBytes)在写入时独立淘汰。
|
||||
var cacheEvictionPoolOrder = []string{
|
||||
"hls",
|
||||
"images",
|
||||
"library-covers",
|
||||
}
|
||||
|
||||
// cacheCleanupKeepPercent 是触发清理后回落到的水位(占上限的百分比)。
|
||||
// 留出余量,避免刚清完又被下一次写入立刻顶到上限。
|
||||
const cacheCleanupKeepPercent = 80
|
||||
|
||||
// tempCacheFile 判断文件名是否是缓存写入中的临时产物。这些文件正在被并发
|
||||
// 写入,容量统计和清理都应跳过。
|
||||
func tempCacheFile(name string) bool {
|
||||
if tempImageCacheFile(name) {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(name, ".part") {
|
||||
return true
|
||||
}
|
||||
// 各缓存目录的原子写入临时文件前缀:.content- / .cover- / .audio- / .js-cache-。
|
||||
return strings.HasPrefix(name, ".content-") ||
|
||||
strings.HasPrefix(name, ".cover-") ||
|
||||
strings.HasPrefix(name, ".audio-") ||
|
||||
strings.HasPrefix(name, ".js-cache-")
|
||||
}
|
||||
|
||||
// collectCachePoolFiles 递归收集一个普通缓存池里的候选文件,按 mtime 从旧到新。
|
||||
func collectCachePoolFiles(root, pool string) []cacheEvictionEntry {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil
|
||||
}
|
||||
var out []cacheEvictionEntry
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
name := info.Name()
|
||||
if tempCacheFile(name) || strings.HasSuffix(name, ".fail") {
|
||||
return nil
|
||||
}
|
||||
out = append(out, cacheEvictionEntry{
|
||||
path: path,
|
||||
pool: pool,
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) })
|
||||
return out
|
||||
}
|
||||
|
||||
// collectImageCacheEntries 收集图片缓存候选文件。返回顺序沿用图片池的优先级:
|
||||
// 先原图、后派生成品,池内按 mtime 从旧到新。
|
||||
func collectImageCacheEntries(imagesDir string) []cacheEvictionEntry {
|
||||
if strings.TrimSpace(imagesDir) == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(imagesDir); err != nil {
|
||||
return nil
|
||||
}
|
||||
pools := ImageCachePools(imagesDir, 0)
|
||||
var entries []imageCacheFileEntry
|
||||
var failMarkers []imageCacheFileEntry
|
||||
for i, pool := range pools {
|
||||
collectImageCachePoolFiles(pool, i, &entries, &failMarkers)
|
||||
}
|
||||
ordered := evictionOrder(entries, map[string]bool{})
|
||||
out := make([]cacheEvictionEntry, 0, len(ordered))
|
||||
for _, e := range ordered {
|
||||
out = append(out, cacheEvictionEntry{
|
||||
path: e.path,
|
||||
pool: "images",
|
||||
size: e.size,
|
||||
modTime: e.modTime,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// removeEmptyDirsUnder 删除 root 下淘汰后留下的空子目录(不含 root 本身)。
|
||||
func removeEmptyDirsUnder(root string) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return
|
||||
}
|
||||
dirs := []string{}
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info == nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() && path != root {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
_ = os.Remove(dirs[i])
|
||||
}
|
||||
}
|
||||
|
||||
// PruneCache 按全局容量池清理磁盘缓存。
|
||||
//
|
||||
// 总量口径是 hls、images、library-covers 三个池之和;reader-audio 及正文缓存、
|
||||
// 书源文件缓存都不参与这里的总量计算——reader-audio 由自身固定配额(当前
|
||||
// 1GiB)在写入时淘汰,正文缓存和书源文件缓存另有自己的 TTL / 配额。
|
||||
// 超过 totalBytes 时严格按 hls → images → library-covers 的顺序淘汰最旧文件,
|
||||
// 直到回落到上限的 80%。
|
||||
// originalsMaxBytes 是 images 原图池的独立子配额,在总量淘汰之后单独执行
|
||||
// (即使总量没超也会把原图压回配额)。
|
||||
func PruneCache(cacheDir string, totalBytes, originalsMaxBytes int64) (CacheCleanupResult, error) {
|
||||
result := CacheCleanupResult{FreedByPool: map[string]int64{}}
|
||||
if strings.TrimSpace(cacheDir) == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
imagesDir := filepath.Join(cacheDir, "images")
|
||||
ordered := make([]cacheEvictionEntry, 0, 1024)
|
||||
for _, name := range cacheEvictionPoolOrder {
|
||||
if name == "images" {
|
||||
ordered = append(ordered, collectImageCacheEntries(imagesDir)...)
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, collectCachePoolFiles(filepath.Join(cacheDir, name), name)...)
|
||||
}
|
||||
|
||||
result.TotalFilesBefore = len(ordered)
|
||||
for _, entry := range ordered {
|
||||
result.TotalBytesBefore += entry.size
|
||||
}
|
||||
result.RemainingBytes = result.TotalBytesBefore
|
||||
|
||||
// 1. 全局总量:严格按池优先级淘汰,直到回落到水位。
|
||||
if totalBytes > 0 && result.RemainingBytes > totalBytes {
|
||||
target := totalBytes * cacheCleanupKeepPercent / 100
|
||||
for _, entry := range ordered {
|
||||
if result.RemainingBytes <= target {
|
||||
break
|
||||
}
|
||||
if err := os.Remove(entry.path); err != nil {
|
||||
continue
|
||||
}
|
||||
result.DeletedFiles++
|
||||
result.FreedBytes += entry.size
|
||||
result.RemainingBytes -= entry.size
|
||||
result.FreedByPool[entry.pool] += entry.size
|
||||
_ = os.Remove(entry.path + ".fail")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 图片原图子配额独立于总量:即使总量没超,也要把原图压回自己的配额,
|
||||
// 同时清掉旧命名变体和孤儿失败标记。
|
||||
if _, err := os.Stat(imagesDir); err == nil {
|
||||
pools := ImageCachePools(imagesDir, originalsMaxBytes)
|
||||
if sub, err := PruneImageCachePools(pools, 0); err == nil {
|
||||
result.DeletedFiles += sub.DeletedFiles
|
||||
result.FreedBytes += sub.FreedBytes
|
||||
result.RemainingBytes -= sub.FreedBytes
|
||||
result.FreedByPool["images"] += sub.FreedBytes
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range cacheEvictionPoolOrder {
|
||||
removeEmptyDirsUnder(filepath.Join(cacheDir, name))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerJobCleanImageCache(t *testing.T) {
|
||||
func TestSchedulerJobCleanCache(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
imagesDir := filepath.Join(cacheRoot, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o750); err != nil {
|
||||
@@ -148,15 +148,117 @@ func TestSchedulerJobCleanImageCache(t *testing.T) {
|
||||
|
||||
scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot)
|
||||
// Set limit to 1MB; our file is 2MB -> should be pruned
|
||||
scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024}
|
||||
scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
|
||||
return CacheCleanupPolicy{TotalBytes: 1 * 1024 * 1024}
|
||||
})
|
||||
|
||||
if err := scheduler.jobCleanImageCache(context.Background()); err != nil {
|
||||
t.Fatalf("jobCleanImageCache failed: %v", err)
|
||||
if err := scheduler.jobCleanCache(context.Background()); err != nil {
|
||||
t.Fatalf("jobCleanCache failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(f); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected file to be pruned, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneCacheEvictionOrder 验证总量超限时严格按
|
||||
// hls → images → library-covers 的顺序淘汰,且 reader-audio 不参与。
|
||||
func TestPruneCacheEvictionOrder(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
audio := filepath.Join(cacheRoot, "reader-audio", "old.mp3")
|
||||
hls := filepath.Join(cacheRoot, "hls", "job", "seg.ts")
|
||||
image := filepath.Join(cacheRoot, "images", "originals", "poster.jpg")
|
||||
cover := filepath.Join(cacheRoot, "library-covers", "cover.jpg")
|
||||
for _, p := range []string{audio, hls, image, cover} {
|
||||
writePoolFile(t, p, 1024*1024, now.Add(-time.Hour))
|
||||
}
|
||||
|
||||
// 计入总量的只有 hls+images+cover = 3MB,上限 2MB -> 回落到 1.6MB:
|
||||
// 按顺序删 hls、images 两个文件即可;reader-audio 与封面都应保留。
|
||||
res, err := PruneCache(cacheRoot, 2*1024*1024, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 2 {
|
||||
t.Fatalf("deleted files = %d, want 2", res.DeletedFiles)
|
||||
}
|
||||
mustExist(t, audio)
|
||||
mustNotExist(t, hls)
|
||||
mustNotExist(t, image)
|
||||
mustExist(t, cover)
|
||||
if _, ok := res.FreedByPool["reader-audio"]; ok {
|
||||
t.Fatalf("reader-audio must not be part of the global pool, got %+v", res.FreedByPool)
|
||||
}
|
||||
if res.FreedByPool["hls"] != 1024*1024 || res.FreedByPool["images"] != 1024*1024 {
|
||||
t.Fatalf("unexpected per-pool stats: %+v", res.FreedByPool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneCacheLeavesReaderAudioAlone reader-audio 有独立的 1GB 写入时淘汰,
|
||||
// 不参与总容量统计:即使它单独就超过全局上限,全局任务也不应删它。
|
||||
func TestPruneCacheLeavesReaderAudioAlone(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
audio := filepath.Join(cacheRoot, "reader-audio", "book", "chapter.mp3")
|
||||
writePoolFile(t, audio, 4*1024*1024, time.Now().Add(-time.Hour))
|
||||
|
||||
res, err := PruneCache(cacheRoot, 1*1024*1024, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 0 {
|
||||
t.Fatalf("deleted files = %d, want 0", res.DeletedFiles)
|
||||
}
|
||||
if res.TotalBytesBefore != 0 {
|
||||
t.Fatalf("reader-audio should not count toward total, got %d bytes", res.TotalBytesBefore)
|
||||
}
|
||||
mustExist(t, audio)
|
||||
}
|
||||
|
||||
// TestPruneCacheAppliesOriginalsSubQuota 验证总量没超时,图片原图子配额仍然生效。
|
||||
func TestPruneCacheAppliesOriginalsSubQuota(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
oldOriginal := filepath.Join(cacheRoot, "images", "originals", "old.jpg")
|
||||
newOriginal := filepath.Join(cacheRoot, "images", "originals", "new.jpg")
|
||||
derived := currentVariantPath(filepath.Join(cacheRoot, "images", "resize"), "d", 480, 600, 80)
|
||||
writePoolFile(t, oldOriginal, 800, now.Add(-2*time.Hour))
|
||||
writePoolFile(t, newOriginal, 800, now.Add(-time.Hour))
|
||||
writePoolFile(t, derived, 800, now.Add(-time.Hour))
|
||||
|
||||
// 总量上限很大(不触发全局淘汰),原图子配额 1000B -> 删到 800B。
|
||||
res, err := PruneCache(cacheRoot, 100*1024*1024, 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 1 {
|
||||
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
|
||||
}
|
||||
mustNotExist(t, oldOriginal)
|
||||
mustExist(t, newOriginal)
|
||||
mustExist(t, derived)
|
||||
}
|
||||
|
||||
// TestPruneCacheOldestFirstWithinPool 验证同一池内先删最旧的文件。
|
||||
func TestPruneCacheOldestFirstWithinPool(t *testing.T) {
|
||||
cacheRoot := t.TempDir()
|
||||
now := time.Now()
|
||||
|
||||
oldHLS := filepath.Join(cacheRoot, "hls", "job-a", "seg1.ts")
|
||||
newHLS := filepath.Join(cacheRoot, "hls", "job-b", "seg1.ts")
|
||||
writePoolFile(t, oldHLS, 1024*1024, now.Add(-2*time.Hour))
|
||||
writePoolFile(t, newHLS, 1024*1024, now.Add(-time.Minute))
|
||||
|
||||
// 上限 1.25MB -> 回落水位 1MB:删掉最旧的 1MB 后正好达标。
|
||||
res, err := PruneCache(cacheRoot, 1280*1024, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("prune failed: %v", err)
|
||||
}
|
||||
if res.DeletedFiles != 1 {
|
||||
t.Fatalf("deleted files = %d, want 1", res.DeletedFiles)
|
||||
}
|
||||
mustNotExist(t, oldHLS)
|
||||
mustExist(t, newHLS)
|
||||
}
|
||||
|
||||
@@ -199,17 +199,19 @@ func imageResizeConcurrency() int {
|
||||
return n
|
||||
}
|
||||
|
||||
// Prune removes oldest cached images until disk usage is within the configured limit.
|
||||
// 原图池按独立配额优先淘汰,客户端热路径读取的成品图最后淘汰。
|
||||
// Prune 只执行图片原图子配额与旧缓存清理;整个磁盘缓存的总量淘汰由调度器的
|
||||
// cache_cleanup 任务(PruneCache)负责。
|
||||
func (p *ImageProxy) Prune() (PruneImageCacheResult, error) {
|
||||
if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 {
|
||||
if p.cfg == nil {
|
||||
return PruneImageCacheResult{}, nil
|
||||
}
|
||||
var originalsMaxBytes int64
|
||||
if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||||
originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024
|
||||
}
|
||||
totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024
|
||||
if originalsMaxBytes <= 0 {
|
||||
return PruneImageCacheResult{}, nil
|
||||
}
|
||||
pools := ImageCachePools(p.cacheDir, originalsMaxBytes)
|
||||
return PruneImageCachePools(pools, totalBytes)
|
||||
return PruneImageCachePools(pools, 0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 远端音轨的本地落盘缓存(媒体代理 /api/reader/media 使用)。
|
||||
//
|
||||
// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401
|
||||
// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新
|
||||
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象」把整段音频
|
||||
// 落盘(丢掉 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
|
||||
// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。
|
||||
|
||||
const (
|
||||
audioMediaDirName = "reader-audio"
|
||||
audioMediaTTL = 7 * 24 * time.Hour
|
||||
// audioMediaMaxBytes 音频缓存的总容量上限。超限时按 mtime 淘汰最旧的。
|
||||
//
|
||||
// 这个池不参与 cache_cleanup 的全局总量统计(hls/images/library-covers
|
||||
// 三池),而是在每次下载成功后自行淘汰,见 pruneAudioMediaCache。
|
||||
audioMediaMaxBytes = 1 << 30 // 1GiB
|
||||
audioMediaPrunePeriod = 10 * time.Minute
|
||||
audioMediaPruneKeepPct = 90
|
||||
|
||||
// audioPartialSuffix 未下完的音轨分片后缀。
|
||||
//
|
||||
// 分片跨请求保留:首播失败后用户再点一次、或播放器重连,都能用 Range 接着下,
|
||||
// 不必从第 0 字节重来。分片名带 .part 后缀,不会被 OpenCachedRemoteAudio 当成
|
||||
// 完整音轨下发。
|
||||
audioPartialSuffix = ".part"
|
||||
// audioMediaObjectMaxBytes 单个音频对象的落盘上限。有声书一章约 3–5MB,
|
||||
// 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。
|
||||
audioMediaObjectMaxBytes = 64 << 20
|
||||
// audioDownloadRetryDelay 断点续传重试的退避基数,实际等待为 (attempt-1) 倍。
|
||||
audioDownloadRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
audioMediaPruneMu sync.Mutex
|
||||
audioMediaPrunedAt time.Time
|
||||
)
|
||||
|
||||
// audioMediaDir 音频缓存根目录(与转码缓存同一套目录约定)。
|
||||
func (s *ReaderService) audioMediaDir() string {
|
||||
if s == nil || s.cfg == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
|
||||
if base == "" {
|
||||
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
|
||||
base = filepath.Join(dataDir, "cache")
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, audioMediaDirName)
|
||||
}
|
||||
|
||||
// audioMediaVolatileParams 是换票时会变、但与「这是哪段音频」无关的 query 参数。
|
||||
// 缓存键必须忽略它们,否则每次换票都打不中缓存。
|
||||
//
|
||||
// 不在这张表里的参数一律保留:有些书源的直链形如
|
||||
// https://host/audio?id=12345,路径固定、靠 query 区分章节,把 query 整个丢掉
|
||||
// 会让第二集命中第一集的缓存,直接播错集。
|
||||
var audioMediaVolatileParams = map[string]bool{
|
||||
"vkey": true, "uid": true, "ct": true, "t": true, "ts": true,
|
||||
"token": true, "sign": true, "signature": true, "sig": true,
|
||||
"expires": true, "expire": true, "expiry": true, "auth": true,
|
||||
"nonce": true, "rand": true, "rnd": true, "r": true, "guid": true,
|
||||
"ftime": true,
|
||||
}
|
||||
|
||||
// audioMediaCacheKey 缓存键:书 + 媒体对象身份。
|
||||
//
|
||||
// 身份 = scheme://host/path + 非签名类 query。签名/时效参数(vkey、token、
|
||||
// 时间戳等)被剔除,保证换票后仍命中同一条缓存;其余参数保留,避免
|
||||
// 「路径相同、只靠 query 区分」的书源串集。
|
||||
func audioMediaCacheKey(bookID, mediaURL string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(mediaURL))
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
ident := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + u.EscapedPath()
|
||||
if u.RawQuery != "" {
|
||||
if q, parseErr := url.ParseQuery(u.RawQuery); parseErr == nil {
|
||||
var kept []string
|
||||
for k, vs := range q {
|
||||
if audioMediaVolatileParams[strings.ToLower(k)] {
|
||||
continue
|
||||
}
|
||||
for _, v := range vs {
|
||||
kept = append(kept, k+"="+v)
|
||||
}
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
sort.Strings(kept)
|
||||
ident += "?" + strings.Join(kept, "&")
|
||||
}
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256([]byte(bookID + "|" + ident))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// RemoteAudioCachePath 返回该音轨的缓存文件路径;无法缓存时返回空串。
|
||||
func (s *ReaderService) RemoteAudioCachePath(bookID, mediaURL string) string {
|
||||
key := audioMediaCacheKey(bookID, mediaURL)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
dir := s.audioMediaDir()
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, key[:2], key+".audio")
|
||||
}
|
||||
|
||||
// IsAudioMediaURL 判断媒体代理这次要拉的是不是音轨。
|
||||
//
|
||||
// 后缀优先:音频书籍里也会出现封面/插图(.jpg)或 HLS 播放列表(.m3u8),
|
||||
// 这些不能当成音轨整段落盘。只有后缀为空(签名直链常见形态)时才退回按
|
||||
// 书籍类型判断。
|
||||
func IsAudioMediaURL(rawURL string, bookType int) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return bookType == 1
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
if ext == "" {
|
||||
return bookType == 1
|
||||
}
|
||||
return audioExtensions[ext]
|
||||
}
|
||||
|
||||
// HasCachedRemoteAudio 判断该音轨是否已有可用缓存(会顺带刷新 mtime)。
|
||||
func (s *ReaderService) HasCachedRemoteAudio(bookID, mediaURL string) bool {
|
||||
f, _, ok := s.OpenCachedRemoteAudio(bookID, mediaURL)
|
||||
if ok {
|
||||
_ = f.Close()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasPartialRemoteAudio 是否有可续传的未完成分片(.part)。
|
||||
//
|
||||
// 调用方据此决定首播策略:有分片说明上一次已经下到一半(用户点过重试、
|
||||
// 或播放器自己重连过),此时「下载完再下发」能透明续上剩余字节;
|
||||
// 没有分片才是真正的冷启动,边下边发换更低的 TTFB。
|
||||
func (s *ReaderService) HasPartialRemoteAudio(bookID, mediaURL string) bool {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(path + audioPartialSuffix)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Size() == 0 {
|
||||
return false
|
||||
}
|
||||
return info.Size() <= audioMediaObjectMaxBytes
|
||||
}
|
||||
|
||||
// OpenCachedRemoteAudio 打开已缓存的音轨(命中返回打开的文件,调用方负责关闭)。
|
||||
// 过期的缓存顺手删掉,未过期则刷新 mtime 供 LRU 淘汰。
|
||||
func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File, os.FileInfo, bool) {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
f, err := os.Open(path) // #nosec G304 -- 路径由服务端哈希生成
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() || info.Size() == 0 {
|
||||
_ = f.Close()
|
||||
return nil, nil, false
|
||||
}
|
||||
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, nil, false
|
||||
}
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(path, now, now)
|
||||
return f, info, true
|
||||
}
|
||||
|
||||
// errAudioObjectTooLarge 上游对象超过单章缓存上限(不是可重试的错误)。
|
||||
var errAudioObjectTooLarge = errors.New("音源超过缓存上限")
|
||||
|
||||
// NoRetryError 标记「换着花样再试也不会有进展」的失败(例如上游正成批拒绝
|
||||
// 签名票、书源根本给不出新票)。带断点续传的下载器收到它会立刻停止,并把
|
||||
// Err 原样交给调用方——继续重试只会让播放器多等十几秒,还会让对方更坚定地
|
||||
// 拒绝我们。
|
||||
type NoRetryError struct{ Err error }
|
||||
|
||||
func (e *NoRetryError) Error() string { return e.Err.Error() }
|
||||
func (e *NoRetryError) Unwrap() error { return e.Err }
|
||||
|
||||
// AudioRangeFetcher 取一次上游音轨响应。
|
||||
//
|
||||
// rangeHeader 为空表示整段请求;否则形如 "bytes=N-",用于从断点续传。
|
||||
// head 是调用方已经嗅探读过、必须补写回文件的前缀(只有整段请求才有)。
|
||||
// 调用方负责在内部完成换票;这里只看状态码和字节。
|
||||
type AudioRangeFetcher func(rangeHeader string) (resp *http.Response, head []byte, err error)
|
||||
|
||||
// AudioStreamStart 首播直通开始的回调:调用方在这里写响应头与状态码。
|
||||
// total 是上游声明的总长度(分块传输时为 -1),contentType 是上游给的类型。
|
||||
type AudioStreamStart func(total int64, contentType string)
|
||||
|
||||
// errAudioStreamCannotRestart 直通模式下遇到必须「从第 0 字节重来」的情况。
|
||||
//
|
||||
// 直通时客户端已经收到了前缀字节,不可能让它把已收到的丢掉重听,所以这类
|
||||
// 分支只能中止这次响应(让客户端判成不完整),并把已下载的分片留给下次续传。
|
||||
var errAudioStreamCannotRestart = errors.New("直通中无法从零重新下载")
|
||||
|
||||
// audioSinkError 标记「写客户端失败」(通常是播放器关掉了连接)。
|
||||
// 与写盘失败区分开:写盘失败说明本地有问题,写客户端失败只需停止下载。
|
||||
type audioSinkError struct{ err error }
|
||||
|
||||
func (e *audioSinkError) Error() string { return "写入客户端失败: " + e.err.Error() }
|
||||
func (e *audioSinkError) Unwrap() error { return e.err }
|
||||
|
||||
// DownloadRemoteAudio 带断点续传地把整段音轨写进缓存,返回最终文件路径。
|
||||
//
|
||||
// 为什么需要续传:腾讯 CDN 的跨境边缘常在传输中途 RST,旧实现每次重试都从
|
||||
// 第 0 字节重下,一章 3MB 白下几次就把首播拖到几十秒。这里把已写入的字节留在
|
||||
// <cache>.part 里,下一次用 Range: bytes=N- 续上;分片跨请求保留,用户点重试
|
||||
// 或播放器重连都能接着下。
|
||||
//
|
||||
// 上游忽略 Range 直接回 200、或分片区间对不上时,丢弃分片从头重来。
|
||||
// 下载过程中的失败不会留下「看起来完整」的缓存文件:只有字节数与上游声明的
|
||||
// 总长一致才会原子改名到正式路径。
|
||||
func (s *ReaderService) DownloadRemoteAudio(
|
||||
ctx context.Context,
|
||||
bookID, mediaURL string,
|
||||
attempts int,
|
||||
fetch AudioRangeFetcher,
|
||||
) (string, error) {
|
||||
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, nil, nil)
|
||||
}
|
||||
|
||||
// DownloadRemoteAudioStreaming 首播直通:一边落盘一边把字节写给 sink。
|
||||
//
|
||||
// 与 DownloadRemoteAudio 的区别只有一处——不再等整段下完才返回,第一个字节
|
||||
// 到达就通过 onStart 写好响应头并开始外发,慢链路上的首播不必先等 3–5MB。
|
||||
//
|
||||
// 代价是失败语义变严:客户端已经收到前缀字节,任何「必须从 0 重来」的情况
|
||||
// (上游忽略 Range 回 200、分片区间对不上、416)都只能中止这次响应,
|
||||
// 不能像普通模式那样静默重下。已写入的分片仍然保留,下次可续传。
|
||||
func (s *ReaderService) DownloadRemoteAudioStreaming(
|
||||
ctx context.Context,
|
||||
bookID, mediaURL string,
|
||||
attempts int,
|
||||
fetch AudioRangeFetcher,
|
||||
onStart AudioStreamStart,
|
||||
sink io.Writer,
|
||||
) (string, error) {
|
||||
if sink == nil {
|
||||
return "", errors.New("直通下载未提供输出目标")
|
||||
}
|
||||
return s.downloadRemoteAudio(ctx, bookID, mediaURL, attempts, fetch, onStart, sink)
|
||||
}
|
||||
|
||||
// downloadRemoteAudio 是两种下载模式的共同实现。sink 非空即首播直通模式。
|
||||
func (s *ReaderService) downloadRemoteAudio(
|
||||
ctx context.Context,
|
||||
bookID, mediaURL string,
|
||||
attempts int,
|
||||
fetch AudioRangeFetcher,
|
||||
onStart AudioStreamStart,
|
||||
sink io.Writer,
|
||||
) (string, error) {
|
||||
path := s.RemoteAudioCachePath(bookID, mediaURL)
|
||||
if path == "" {
|
||||
return "", errors.New("音频缓存目录未配置")
|
||||
}
|
||||
if fetch == nil {
|
||||
return "", errors.New("音频下载器未配置")
|
||||
}
|
||||
if attempts <= 0 {
|
||||
attempts = 1
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return "", fmt.Errorf("创建音频缓存目录失败: %w", err)
|
||||
}
|
||||
partPath := path + audioPartialSuffix
|
||||
var offset int64
|
||||
if info, statErr := os.Stat(partPath); statErr == nil && info.Mode().IsRegular() {
|
||||
offset = info.Size()
|
||||
// 分片太旧或超过上限:续传没有意义,直接丢掉重下。
|
||||
if offset > audioMediaObjectMaxBytes || info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
|
||||
_ = os.Remove(partPath)
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建音频缓存文件失败: %w", err)
|
||||
}
|
||||
// onStart 只应触发一次:直通模式下响应头只能写一次。
|
||||
started := false
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
if attempt > 1 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = file.Close()
|
||||
return "", ctx.Err()
|
||||
case <-time.After(audioDownloadRetryDelay * time.Duration(attempt-1)):
|
||||
}
|
||||
}
|
||||
|
||||
rangeHeader := ""
|
||||
if offset > 0 {
|
||||
rangeHeader = fmt.Sprintf("bytes=%d-", offset)
|
||||
}
|
||||
resp, head, fetchErr := fetch(rangeHeader)
|
||||
if fetchErr != nil {
|
||||
var noRetry *NoRetryError
|
||||
if errors.As(fetchErr, &noRetry) {
|
||||
_ = file.Close()
|
||||
// 已经写下的分片留着:上游恢复后用户再点一次能接着续。
|
||||
if offset == 0 {
|
||||
_ = os.Remove(partPath)
|
||||
}
|
||||
return "", noRetry.Err
|
||||
}
|
||||
lastErr = fetchErr
|
||||
continue
|
||||
}
|
||||
if resp == nil {
|
||||
lastErr = errors.New("音源响应为空")
|
||||
continue
|
||||
}
|
||||
|
||||
expected := int64(-1)
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
start, total, ok := parseContentRange(resp.Header.Get("Content-Range"))
|
||||
if !ok || offset == 0 || start != offset {
|
||||
drainAndClose(resp.Body)
|
||||
if sink != nil && offset > 0 {
|
||||
// 已经发给客户端 offset 字节:重来会让客户端收到重复内容。
|
||||
// 分片保持原样,下次请求从这里续。
|
||||
_ = file.Close()
|
||||
return "", errAudioStreamCannotRestart
|
||||
}
|
||||
offset = 0
|
||||
_ = file.Truncate(0)
|
||||
lastErr = fmt.Errorf("音源返回的分片区间不可用(%q)", resp.Header.Get("Content-Range"))
|
||||
continue
|
||||
}
|
||||
expected = total
|
||||
case http.StatusOK:
|
||||
// 上游忽略了 Range(或者本来就是整段请求):只能从第 0 字节写。
|
||||
if offset > 0 {
|
||||
drainAndClose(resp.Body)
|
||||
if sink != nil {
|
||||
// 同上:已发出去的字节不能撤回,分片留着供下次续传。
|
||||
_ = file.Close()
|
||||
return "", errAudioStreamCannotRestart
|
||||
}
|
||||
offset = 0
|
||||
if truncErr := file.Truncate(0); truncErr != nil {
|
||||
lastErr = fmt.Errorf("重置音频缓存失败: %w", truncErr)
|
||||
continue
|
||||
}
|
||||
}
|
||||
expected = resp.ContentLength
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
// 分片比上游对象还长(对象换过):丢掉分片重来。
|
||||
drainAndClose(resp.Body)
|
||||
if sink != nil && offset > 0 {
|
||||
_ = file.Close()
|
||||
return "", errAudioStreamCannotRestart
|
||||
}
|
||||
offset = 0
|
||||
_ = file.Truncate(0)
|
||||
lastErr = fmt.Errorf("音源分片已失效(%d)", resp.StatusCode)
|
||||
continue
|
||||
default:
|
||||
drainAndClose(resp.Body)
|
||||
lastErr = fmt.Errorf("音源返回 %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
if sink != nil && !started {
|
||||
// 直通模式下要在这里就把状态码与 Content-Length 写出去,客户端才能
|
||||
// 边收边播。写头的失败(客户端已断开)当作停止下载处理。
|
||||
if onStart != nil {
|
||||
onStart(expected, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
started = true
|
||||
}
|
||||
|
||||
if _, seekErr := file.Seek(offset, io.SeekStart); seekErr != nil {
|
||||
drainAndClose(resp.Body)
|
||||
lastErr = fmt.Errorf("音频缓存定位失败: %w", seekErr)
|
||||
continue
|
||||
}
|
||||
var written int64
|
||||
var writeErr error
|
||||
if sink != nil {
|
||||
written, writeErr = writeAudioChunkStream(file, sink, head, resp.Body, audioMediaObjectMaxBytes-offset)
|
||||
} else {
|
||||
written, writeErr = writeAudioChunk(file, head, resp.Body, audioMediaObjectMaxBytes-offset)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
offset += written
|
||||
if errors.Is(writeErr, errAudioObjectTooLarge) {
|
||||
_ = file.Close()
|
||||
_ = os.Remove(partPath)
|
||||
return "", fmt.Errorf("已重试 %d 次: %w", attempts, writeErr)
|
||||
}
|
||||
var sinkErr *audioSinkError
|
||||
if errors.As(writeErr, &sinkErr) {
|
||||
// 客户端不要了:停止下载,已写入的分片留着供下次续传。
|
||||
_ = file.Close()
|
||||
return "", sinkErr
|
||||
}
|
||||
if writeErr != nil {
|
||||
// 已写入的字节保留在分片里,下一轮从新的断点续传。
|
||||
lastErr = writeErr
|
||||
continue
|
||||
}
|
||||
if expected >= 0 && offset != expected {
|
||||
lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", offset, expected)
|
||||
continue
|
||||
}
|
||||
if offset == 0 {
|
||||
lastErr = errors.New("音源内容为空")
|
||||
continue
|
||||
}
|
||||
if truncErr := file.Truncate(offset); truncErr != nil {
|
||||
_ = file.Close()
|
||||
return "", fmt.Errorf("写入音频缓存失败: %w", truncErr)
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
return "", fmt.Errorf("写入音频缓存失败: %w", closeErr)
|
||||
}
|
||||
if renameErr := os.Rename(partPath, path); renameErr != nil {
|
||||
return "", fmt.Errorf("写入音频缓存失败: %w", renameErr)
|
||||
}
|
||||
s.pruneAudioMediaCache()
|
||||
return path, nil
|
||||
}
|
||||
|
||||
_ = file.Close()
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("音源拉取失败")
|
||||
}
|
||||
if offset > 0 {
|
||||
return "", fmt.Errorf("已重试 %d 次: %w(已缓存 %d 字节,重试可续传)", attempts, lastErr, offset)
|
||||
}
|
||||
return "", fmt.Errorf("已重试 %d 次: %w", attempts, lastErr)
|
||||
}
|
||||
|
||||
// writeAudioChunk 先把已读前缀补写回文件,再把响应体写到 limit 字节为止。
|
||||
// 返回本次实际写入的字节数(出错时也包含已写成功的部分)。
|
||||
func writeAudioChunk(w io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
|
||||
var total int64
|
||||
if len(head) > 0 {
|
||||
n, err := w.Write(head)
|
||||
total += int64(n)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
return total, errAudioObjectTooLarge
|
||||
}
|
||||
n, err := io.Copy(w, io.LimitReader(body, limit-total+1))
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if total > limit {
|
||||
return total, errAudioObjectTooLarge
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// writeAudioChunkStream 同 writeAudioChunk,但同时把字节写给客户端(首播直通)。
|
||||
//
|
||||
// 写盘失败要让整个下载停下来(本地状态不可信,继续发字节等于给客户端一份
|
||||
// 服务端存不下的内容);写客户端失败只说明接收方不要了,用 audioSinkError
|
||||
// 标出来让上层保留分片、安静收尾。两者必须区分,否则播放器一关就把分片删了。
|
||||
func writeAudioChunkStream(w io.Writer, sink io.Writer, head []byte, body io.Reader, limit int64) (int64, error) {
|
||||
var total int64
|
||||
writeBoth := func(p []byte) error {
|
||||
n, err := w.Write(p)
|
||||
total += int64(n)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
if _, sinkErr := sink.Write(p[:n]); sinkErr != nil {
|
||||
return &audioSinkError{err: sinkErr}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(head) > 0 {
|
||||
if err := writeBoth(head); err != nil {
|
||||
return total, err
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
return total, errAudioObjectTooLarge
|
||||
}
|
||||
buf := make([]byte, 32<<10)
|
||||
remaining := limit - total + 1
|
||||
for {
|
||||
if remaining <= 0 {
|
||||
return total, errAudioObjectTooLarge
|
||||
}
|
||||
chunk := buf
|
||||
if int64(len(chunk)) > remaining {
|
||||
chunk = chunk[:remaining]
|
||||
}
|
||||
n, readErr := body.Read(chunk)
|
||||
if n > 0 {
|
||||
if err := writeBoth(chunk[:n]); err != nil {
|
||||
return total, err
|
||||
}
|
||||
remaining -= int64(n)
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
return total, readErr
|
||||
}
|
||||
}
|
||||
if total > limit {
|
||||
return total, errAudioObjectTooLarge
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// parseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
|
||||
// 总长度未知("*")时 total 为 -1。
|
||||
func parseContentRange(v string) (start, total int64, ok bool) {
|
||||
v = strings.TrimSpace(v)
|
||||
const prefix = "bytes "
|
||||
if len(v) <= len(prefix) || !strings.EqualFold(v[:len(prefix)], prefix) {
|
||||
return 0, -1, false
|
||||
}
|
||||
v = strings.TrimSpace(v[len(prefix):])
|
||||
slash := strings.IndexByte(v, '/')
|
||||
if slash < 0 {
|
||||
return 0, -1, false
|
||||
}
|
||||
span, size := v[:slash], strings.TrimSpace(v[slash+1:])
|
||||
dash := strings.IndexByte(span, '-')
|
||||
if dash <= 0 {
|
||||
return 0, -1, false
|
||||
}
|
||||
start, err := strconv.ParseInt(strings.TrimSpace(span[:dash]), 10, 64)
|
||||
if err != nil || start < 0 {
|
||||
return 0, -1, false
|
||||
}
|
||||
if size == "" || size == "*" {
|
||||
return start, -1, true
|
||||
}
|
||||
total, err = strconv.ParseInt(size, 10, 64)
|
||||
if err != nil || total <= 0 {
|
||||
return start, -1, true
|
||||
}
|
||||
return start, total, true
|
||||
}
|
||||
|
||||
// drainAndClose 丢弃剩余响应体并关闭连接(尽量让上游把连接收回去)。
|
||||
func drainAndClose(body io.ReadCloser) {
|
||||
if body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(body, 64<<10))
|
||||
_ = body.Close()
|
||||
}
|
||||
|
||||
// pruneAudioMediaCache 低频清理:删过期文件,超容量时按 mtime 淘汰最旧的。
|
||||
func (s *ReaderService) pruneAudioMediaCache() {
|
||||
dir := s.audioMediaDir()
|
||||
if dir == "" {
|
||||
return
|
||||
}
|
||||
audioMediaPruneMu.Lock()
|
||||
if time.Since(audioMediaPrunedAt) < audioMediaPrunePeriod {
|
||||
audioMediaPruneMu.Unlock()
|
||||
return
|
||||
}
|
||||
audioMediaPrunedAt = time.Now()
|
||||
audioMediaPruneMu.Unlock()
|
||||
|
||||
type entry struct {
|
||||
path string
|
||||
size int64
|
||||
mod time.Time
|
||||
}
|
||||
var entries []entry
|
||||
var total int64
|
||||
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, infoErr := d.Info()
|
||||
if infoErr != nil {
|
||||
return nil
|
||||
}
|
||||
if info.ModTime().Add(audioMediaTTL).Before(time.Now()) {
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
entries = append(entries, entry{path: path, size: info.Size(), mod: info.ModTime()})
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if total <= audioMediaMaxBytes {
|
||||
return
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].mod.Before(entries[j].mod) })
|
||||
target := int64(audioMediaMaxBytes) * audioMediaPruneKeepPct / 100
|
||||
for _, e := range entries {
|
||||
if total <= target {
|
||||
break
|
||||
}
|
||||
if err := os.Remove(e.path); err == nil {
|
||||
total -= e.size
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
func TestIsAudioMediaURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
bookType int
|
||||
want bool
|
||||
}{
|
||||
{"m4a 直链", "https://cdn.example.com/a.m4a?uid=1&vkey=x", 0, true},
|
||||
{"mp3 直链", "https://cdn.example.com/a.MP3", 0, true},
|
||||
{"音频书里的插图", "https://cdn.example.com/img/cover.jpg", 1, false},
|
||||
{"HLS 播放列表", "https://cdn.example.com/index.m3u8?token=1", 1, false},
|
||||
{"无后缀签名直链(音频书)", "https://cdn.example.com/stream?token=1", 1, true},
|
||||
{"无后缀签名直链(文本/图片书)", "https://cdn.example.com/stream?token=1", 0, false},
|
||||
{"坏地址但书籍是音频", "://bad", 1, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsAudioMediaURL(c.url, c.bookType); got != c.want {
|
||||
t.Errorf("%s: IsAudioMediaURL(%q, %d) = %v,期望 %v", c.name, c.url, c.bookType, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须忽略
|
||||
// 这些签名/时效参数,否则每次换票都会重新下载一遍。
|
||||
func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) {
|
||||
a := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=1&vkey=aaa")
|
||||
b := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=2&vkey=bbb&ct=3")
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("同一对象换票后缓存键应一致: %q vs %q", a, b)
|
||||
}
|
||||
if c := audioMediaCacheKey("book-2", "https://cdn.example.com/long/a.m4a"); c == a {
|
||||
t.Fatalf("不同书籍的缓存键不应相同")
|
||||
}
|
||||
if d := audioMediaCacheKey("book-1", "https://cdn.example.com/long/b.m4a"); d == a {
|
||||
t.Fatalf("不同对象的缓存键不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioMediaCacheKeyKeepsIdentifyingQuery 只靠 query 区分章节的书源
|
||||
// (路径固定、如 /audio?id=12345)不能被合并成同一个缓存对象,否则第二集
|
||||
// 会命中第一集的缓存直接播错内容。
|
||||
func TestAudioMediaCacheKeyKeepsIdentifyingQuery(t *testing.T) {
|
||||
ep1 := audioMediaCacheKey("book-1", "https://host/audio?id=12345&vkey=aaa")
|
||||
ep2 := audioMediaCacheKey("book-1", "https://host/audio?id=67890&vkey=bbb")
|
||||
if ep1 == "" || ep2 == "" {
|
||||
t.Fatal("缓存键不应为空")
|
||||
}
|
||||
if ep1 == ep2 {
|
||||
t.Fatalf("不同章节(query 不同)的缓存键不应相同,否则会串集")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioMediaCacheKeyIgnoresQueryOrder 非签名参数的顺序不应影响缓存键。
|
||||
func TestAudioMediaCacheKeyIgnoresQueryOrder(t *testing.T) {
|
||||
a := audioMediaCacheKey("book-1", "https://host/a.m4a?id=7&chapter=3&vkey=x")
|
||||
b := audioMediaCacheKey("book-1", "https://host/a.m4a?chapter=3&id=7&vkey=y")
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("参数顺序不应影响缓存键: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── 断点续传下载器 ──────────────────────────────────────────────────────
|
||||
|
||||
func TestParseContentRange(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
start int64
|
||||
total int64
|
||||
wantOK bool
|
||||
}{
|
||||
{"bytes 100-199/1234", 100, 1234, true},
|
||||
{"bytes 0-99/*", 0, -1, true},
|
||||
{"BYTES 500-599/600", 500, 600, true},
|
||||
{"", 0, -1, false},
|
||||
{"bytes */1234", 0, -1, false},
|
||||
{"bytes x-1/2", 0, -1, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
start, total, ok := parseContentRange(c.in)
|
||||
if ok != c.wantOK || start != c.start || total != c.total {
|
||||
t.Errorf("parseContentRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
|
||||
c.in, start, total, ok, c.start, c.total, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errAfterReader 读到 data 末尾后返回 err,模拟上游中途 RST/提前 EOF。
|
||||
type errAfterReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *errAfterReader) Read(p []byte) (int, error) {
|
||||
if r.pos >= len(r.data) {
|
||||
return 0, r.err
|
||||
}
|
||||
n := copy(p, r.data[r.pos:])
|
||||
r.pos += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (r *errAfterReader) Close() error { return nil }
|
||||
|
||||
func newAudioDownloadTestService(t *testing.T) *ReaderService {
|
||||
t.Helper()
|
||||
return &ReaderService{cfg: &config.Config{Cache: config.CacheConfig{CacheDir: t.TempDir()}}}
|
||||
}
|
||||
|
||||
func okResponse(body []byte) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
ContentLength: int64(len(body)),
|
||||
Header: http.Header{"Content-Type": {"audio/mp4"}},
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func rangeResponse(header string, body []byte) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: http.Header{"Content-Range": {header}},
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioResumesAfterReset 上游写到一半断开后,第二轮必须用
|
||||
// Range 从断点续传,最终文件与上游内容逐字节一致。
|
||||
func TestDownloadRemoteAudioResumesAfterReset(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/a.m4a?vkey=1"
|
||||
payload := bytes.Repeat([]byte("0123456789"), 500) // 5000 字节
|
||||
const firstChunk = 2000
|
||||
|
||||
var ranges []string
|
||||
attempt := 0
|
||||
path, err := svc.DownloadRemoteAudio(t.Context(), "book-1", mediaURL, 3,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
ranges = append(ranges, rangeHeader)
|
||||
attempt++
|
||||
if attempt == 1 {
|
||||
// 声明全量长度,只写 2000 字节就断开。
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
ContentLength: int64(len(payload)),
|
||||
Header: http.Header{"Content-Type": {"audio/mp4"}},
|
||||
Body: &errAfterReader{
|
||||
data: payload[:firstChunk],
|
||||
err: io.ErrUnexpectedEOF,
|
||||
},
|
||||
}, nil, nil
|
||||
}
|
||||
if rangeHeader != fmt.Sprintf("bytes=%d-", firstChunk) {
|
||||
t.Fatalf("第二轮应请求断点续传,实际 Range=%q", rangeHeader)
|
||||
}
|
||||
return rangeResponse(
|
||||
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
|
||||
payload[firstChunk:],
|
||||
), nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("续传后应当成功: %v", err)
|
||||
}
|
||||
got, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("读缓存文件失败: %v", readErr)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("续传后的内容不完整: %d 字节,期望 %d", len(got), len(payload))
|
||||
}
|
||||
if len(ranges) != 2 || ranges[0] != "" {
|
||||
t.Fatalf("请求序列 = %q,期望首次整段、第二次续传", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioResumesAcrossCalls 上一次请求失败留下的分片,下一次
|
||||
// (用户点重试)要能直接接着下,而不是从第 0 字节重来。
|
||||
func TestDownloadRemoteAudioResumesAcrossCalls(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/b.m4a"
|
||||
payload := bytes.Repeat([]byte("abcdefgh"), 1000)
|
||||
const firstChunk = 3000
|
||||
|
||||
// 第一次调用:上游只给 3000 字节就断,重试也没用(每次都 200 + 截断)。
|
||||
_, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
ContentLength: int64(len(payload)),
|
||||
Header: http.Header{"Content-Type": {"audio/mp4"}},
|
||||
Body: &errAfterReader{
|
||||
data: payload[:firstChunk],
|
||||
err: io.ErrUnexpectedEOF,
|
||||
},
|
||||
}, nil, nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("上游截断时应当失败")
|
||||
}
|
||||
|
||||
// 第二次调用:直接请求 bytes=3000-,续完。
|
||||
var seenRange string
|
||||
path, err := svc.DownloadRemoteAudio(t.Context(), "book-2", mediaURL, 1,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
seenRange = rangeHeader
|
||||
return rangeResponse(
|
||||
fmt.Sprintf("bytes %d-%d/%d", firstChunk, len(payload)-1, len(payload)),
|
||||
payload[firstChunk:],
|
||||
), nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("跨请求续传应当成功: %v", err)
|
||||
}
|
||||
if seenRange != fmt.Sprintf("bytes=%d-", firstChunk) {
|
||||
t.Fatalf("应当从上次的断点续传,实际 Range=%q", seenRange)
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("跨请求续传后的内容不完整: %d 字节", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioRestartsWhenRangeIgnored 上游忽略 Range 仍回 200 时,
|
||||
// 必须丢掉分片从头写,不能把两段字节拼在一起。
|
||||
func TestDownloadRemoteAudioRestartsWhenRangeIgnored(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/c.m4a"
|
||||
payload := bytes.Repeat([]byte("xyz9"), 500)
|
||||
|
||||
var ranges []string
|
||||
attempt := 0
|
||||
path, err := svc.DownloadRemoteAudio(t.Context(), "book-3", mediaURL, 3,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
ranges = append(ranges, rangeHeader)
|
||||
attempt++
|
||||
if attempt == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
ContentLength: int64(len(payload)),
|
||||
Header: http.Header{"Content-Type": {"audio/mp4"}},
|
||||
Body: &errAfterReader{data: payload[:700], err: io.ErrUnexpectedEOF},
|
||||
}, nil, nil
|
||||
}
|
||||
// 无视 Range,整段回 200。
|
||||
return okResponse(payload), nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("应当成功: %v", err)
|
||||
}
|
||||
if ranges[1] == "" {
|
||||
t.Fatalf("第二轮应当带 Range")
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("忽略 Range 后必须从头重写,实际 %d 字节(期望 %d)", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioStopsOnNoRetry 上游成批拒绝时(NoRetryError),
|
||||
// 下载器要立刻收手,不能把同一张被拒的票再打几遍。
|
||||
func TestDownloadRemoteAudioStopsOnNoRetry(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
denied := errors.New("音源暂时被上游拒绝(HTTP 401)")
|
||||
|
||||
calls := 0
|
||||
_, err := svc.DownloadRemoteAudio(t.Context(), "book-4", "https://cdn.example.com/long/d.m4a", 4,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
calls++
|
||||
return nil, nil, &NoRetryError{Err: denied}
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("应原样返回上游拒绝原因,实际 %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("不可重试的失败只应请求 1 次,实际 %d 次", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 首播直通(边下边发) ────────────────────────────────────────────────
|
||||
|
||||
// TestDownloadRemoteAudioStreamingForwardsWhileDownloading 直通模式:字节必须
|
||||
// 在下载过程中就到达 sink,而不是等整段下完才一次性写出去(首播 TTFB)。
|
||||
func TestDownloadRemoteAudioStreamingForwardsWhileDownloading(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/stream.m4a? vkey=1"
|
||||
payload := bytes.Repeat([]byte("stream!"), 1000)
|
||||
|
||||
var sink bytes.Buffer
|
||||
var startedTotal int64
|
||||
// 用「读到一半时 sink 里已经有数据」来证明确实在边下边发。
|
||||
var midLength int
|
||||
path, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-s", mediaURL, 1,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
if rangeHeader != "" {
|
||||
t.Fatalf("首播直通不应带 Range,实际 %q", rangeHeader)
|
||||
}
|
||||
return okResponse(payload), nil, nil
|
||||
},
|
||||
func(total int64, contentType string) {
|
||||
startedTotal = total
|
||||
// 头部回调必须早于任何字节下发。
|
||||
if sink.Len() != 0 {
|
||||
t.Errorf("写响应头时不应已有字节写出")
|
||||
}
|
||||
},
|
||||
writerFunc(func(p []byte) (int, error) {
|
||||
if midLength == 0 && sink.Len() == 0 && len(p) > 0 {
|
||||
midLength = sink.Len()
|
||||
}
|
||||
return sink.Write(p)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("直通下载失败: %v", err)
|
||||
}
|
||||
if startedTotal != int64(len(payload)) {
|
||||
t.Fatalf("头部回调应拿到上游总长 %d,实际 %d", len(payload), startedTotal)
|
||||
}
|
||||
if !bytes.Equal(sink.Bytes(), payload) {
|
||||
t.Fatalf("下发的字节不完整: %d 字节,期望 %d", sink.Len(), len(payload))
|
||||
}
|
||||
// 下载完成后缓存文件必须完整可命中。
|
||||
got, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
t.Fatalf("读缓存文件失败: %v", readErr)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("落盘内容不完整: %d 字节", len(got))
|
||||
}
|
||||
if !svc.HasCachedRemoteAudio("book-s", mediaURL) {
|
||||
t.Fatal("直通下完后应当命中缓存")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway 客户端中途断开时
|
||||
// 停止下载,但保留分片供下次续传(不能把已下的字节白扔掉)。
|
||||
func TestDownloadRemoteAudioStreamingStopsWhenClientGoesAway(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/gone.m4a"
|
||||
payload := bytes.Repeat([]byte("abcdefgh"), 2000)
|
||||
clientGone := errors.New("client closed connection")
|
||||
|
||||
var sink bytes.Buffer
|
||||
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-g", mediaURL, 1,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
return okResponse(payload), nil, nil
|
||||
},
|
||||
nil,
|
||||
writerFunc(func(p []byte) (int, error) {
|
||||
// 收下前 1000 字节后模拟播放器断开。
|
||||
if sink.Len() >= 1000 {
|
||||
return 0, clientGone
|
||||
}
|
||||
room := 1000 - sink.Len()
|
||||
if len(p) <= room {
|
||||
return sink.Write(p)
|
||||
}
|
||||
n, _ := sink.Write(p[:room])
|
||||
return n, clientGone
|
||||
}),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("客户端断开时应当停止下载")
|
||||
}
|
||||
var sinkErr *audioSinkError
|
||||
if !errors.As(err, &sinkErr) {
|
||||
t.Fatalf("应标记为写客户端失败(便于上层区分),实际 %v", err)
|
||||
}
|
||||
// 分片要留着:这是续传的原料。
|
||||
path := svc.RemoteAudioCachePath("book-g", mediaURL)
|
||||
if path == "" {
|
||||
t.Fatal("缓存路径不应为空")
|
||||
}
|
||||
if info, statErr := os.Stat(path + audioPartialSuffix); statErr != nil || info.Size() == 0 {
|
||||
t.Fatalf("断流后应保留 .part 分片,实际 %v", statErr)
|
||||
}
|
||||
if svc.HasCachedRemoteAudio("book-g", mediaURL) {
|
||||
t.Fatal("中断的下载不能留下完整缓存(否则会下发截断音频)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadRemoteAudioStreamingAbortsOnRestart 直通模式下上游忽略 Range 回
|
||||
// 200(需要从 0 重来)时必须中止,而不是把重复字节拼进客户端已收到的流里。
|
||||
func TestDownloadRemoteAudioStreamingAbortsOnRestart(t *testing.T) {
|
||||
svc := newAudioDownloadTestService(t)
|
||||
const mediaURL = "https://cdn.example.com/long/restart.m4a"
|
||||
payload := bytes.Repeat([]byte("zz"), 100)
|
||||
// 先留一个分片,让第二轮带 Range。
|
||||
partPath := svc.RemoteAudioCachePath("book-r", mediaURL) + audioPartialSuffix
|
||||
if err := os.MkdirAll(filepath.Dir(partPath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(partPath, payload[:50], 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sink bytes.Buffer
|
||||
_, err := svc.DownloadRemoteAudioStreaming(t.Context(), "book-r", mediaURL, 1,
|
||||
func(rangeHeader string) (*http.Response, []byte, error) {
|
||||
// 无视 Range,整段回 200 → 直通无法重来。
|
||||
return okResponse(payload), nil, nil
|
||||
},
|
||||
nil,
|
||||
&sink,
|
||||
)
|
||||
if !errors.Is(err, errAudioStreamCannotRestart) {
|
||||
t.Fatalf("应返回不可重来错误,实际 %v", err)
|
||||
}
|
||||
if sink.Len() != 0 {
|
||||
t.Fatalf("中止前不应向客户端写出字节,实际 %d", sink.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// writerFunc 把函数适配成 io.Writer。
|
||||
type writerFunc func(p []byte) (int, error)
|
||||
|
||||
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
|
||||
@@ -58,7 +58,7 @@ func TestAudioBookTypeFallbackBySource(t *testing.T) {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
}
|
||||
_ = sourceID
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ func TestGetContentForBookExtractsComments(t *testing.T) {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
@@ -282,7 +282,7 @@ func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 上游限流时详情解析不到封面,规则会把页面地址当封面回退。这类记录被存进书架后
|
||||
// 会一直显示破图;打开一次详情页应当用真实封面把它修好。
|
||||
func TestRepairShelfCoverReplacesFallbackURL(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
const origin = "https://api.example.com/book"
|
||||
const bookURL = "https://api.example.com/book?bookId=1"
|
||||
const cover = "https://img.example.com/c.jpg"
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: origin,
|
||||
BookURL: bookURL,
|
||||
Name: "测试书",
|
||||
CoverURL: bookURL, // 规则取空后回退的产物
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.RepairShelfCover(ctx, "u1", origin, bookURL, cover)
|
||||
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CoverURL != cover {
|
||||
t.Fatalf("封面应修为 %q,实际 %q", cover, stored.CoverURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 已经有有效封面的书不能被动覆盖(可能是换源得到的、或用户自己挑的)。
|
||||
func TestRepairShelfCoverKeepsValidCover(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
const origin = "https://api.example.com/book"
|
||||
const bookURL = "https://api.example.com/book?bookId=1"
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: origin,
|
||||
BookURL: bookURL,
|
||||
Name: "测试书",
|
||||
CoverURL: "https://img.example.com/old.jpg",
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.RepairShelfCover(ctx, "u1", origin, bookURL, "https://img.example.com/new.jpg")
|
||||
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.CoverURL != "https://img.example.com/old.jpg" {
|
||||
t.Fatalf("有效封面不应被覆盖,实际 %q", stored.CoverURL)
|
||||
}
|
||||
}
|
||||
@@ -6,32 +6,48 @@ import "testing"
|
||||
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
|
||||
func TestNormalizeCoverURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
name string
|
||||
in string
|
||||
baseURL string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"blank", " ", ""},
|
||||
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
|
||||
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
|
||||
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
|
||||
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
|
||||
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
|
||||
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
|
||||
{"empty", "", "", ""},
|
||||
{"blank", " ", "", ""},
|
||||
{"https", "https://img.example.com/a.jpg", "", "https://img.example.com/a.jpg"},
|
||||
{"http", "http://img.example.com/a.jpg", "", "http://img.example.com/a.jpg"},
|
||||
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "", "HTTPS://img.example.com/a.jpg"},
|
||||
{"trimmed", " https://img.example.com/a.jpg ", "", "https://img.example.com/a.jpg"},
|
||||
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "", "data:image/png;base64,iVBORw0KGgo="},
|
||||
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "", "/api/reader/local/asset?b=1&p=2&s=3"},
|
||||
{
|
||||
"search params envelope",
|
||||
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
|
||||
"",
|
||||
"",
|
||||
},
|
||||
{"text data uri", "data:text/plain;base64,aGk=", "", ""},
|
||||
{"source name", "光遇聚合", "", ""},
|
||||
{"javascript url", "javascript:alert(1)", "", ""},
|
||||
{
|
||||
// 详情规则取不到 $.meta.cover 时按 legado 语义回退成页面地址,
|
||||
// 这个接口地址不是图片,必须丢掉。
|
||||
"fallback to page url",
|
||||
"https://api.example.com/book?bookId=1",
|
||||
"https://api.example.com/book?bookId=1",
|
||||
"",
|
||||
},
|
||||
{
|
||||
"real cover with page url base",
|
||||
"https://img.example.com/a.jpg",
|
||||
"https://api.example.com/book?bookId=1",
|
||||
"https://img.example.com/a.jpg",
|
||||
},
|
||||
{"text data uri", "data:text/plain;base64,aGk=", ""},
|
||||
{"source name", "光遇聚合", ""},
|
||||
{"javascript url", "javascript:alert(1)", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeCoverURL(tc.in); got != tc.want {
|
||||
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
if got := normalizeCoverURL(tc.in, tc.baseURL); got != tc.want {
|
||||
t.Fatalf("normalizeCoverURL(%q, %q) = %q, want %q", tc.in, tc.baseURL, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:媒体直连(客户端自己取音频/漫画图片字节)的回归测试。
|
||||
//
|
||||
// 契约:
|
||||
// - 默认(ContentOptions 零值)必须与历史行为完全一致:媒体地址一律改写成
|
||||
// 服务端签名代理,响应里不出现直连标记与请求头 —— 网页端依赖这一点。
|
||||
// - 客户端显式请求直连 + 书源开启开关时:保留上游原始地址,并下发取媒体
|
||||
// 所需的请求头。
|
||||
// - 客户端请求直连但书源没开开关时:退回代理,不能把未开启的源推到客户端直连。
|
||||
|
||||
// TestMediaDirectDefaultsToProxy 不传直连选项时行为不变(网页端契约)。
|
||||
func TestMediaDirectDefaultsToProxy(t *testing.T) {
|
||||
svc, _, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if len(out.Tracks) == 0 {
|
||||
t.Fatal("应至少有一条音轨")
|
||||
}
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("默认应改写为服务端代理,实际 %q", out.Tracks[0])
|
||||
}
|
||||
if out.MediaDirect {
|
||||
t.Fatal("默认不应标记为直连")
|
||||
}
|
||||
if len(out.MediaHeaders) != 0 {
|
||||
t.Fatalf("代理模式不应下发取媒体请求头,实际 %v", out.MediaHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaDirectRequiresSourceSwitch 书源没开开关时,即使客户端请求直连也只给代理。
|
||||
func TestMediaDirectRequiresSourceSwitch(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if out.MediaDirect {
|
||||
t.Fatal("书源未开启直连,不应标记为直连")
|
||||
}
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("书源未开启直连时仍应走代理,实际 %q", out.Tracks[0])
|
||||
}
|
||||
_ = srv
|
||||
}
|
||||
|
||||
// TestMediaDirectKeepsUpstreamURL 开启开关后:地址是上游原始地址,并下发请求头。
|
||||
func TestMediaDirectKeepsUpstreamURL(t *testing.T) {
|
||||
svc, srv, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
|
||||
t.Fatalf("开启直连失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if !out.MediaDirect {
|
||||
t.Fatal("书源已开启直连,响应应标记 media_direct")
|
||||
}
|
||||
if len(out.Tracks) == 0 {
|
||||
t.Fatal("应至少有一条音轨")
|
||||
}
|
||||
track := out.Tracks[0]
|
||||
if strings.HasPrefix(track, "/api/") {
|
||||
t.Fatalf("直连模式不该返回服务端代理地址,实际 %q", track)
|
||||
}
|
||||
if !strings.HasPrefix(track, srv.URL+"/media") {
|
||||
t.Fatalf("直连模式应保留上游原始地址,实际 %q", track)
|
||||
}
|
||||
// 取媒体需要的防盗链头必须下发,否则客户端拿不到字节。
|
||||
if out.MediaHeaders["Referer"] == "" {
|
||||
t.Fatalf("直连模式应下发 Referer,实际 %v", out.MediaHeaders)
|
||||
}
|
||||
if out.MediaHeaders["User-Agent"] == "" {
|
||||
t.Fatalf("直连模式应下发 User-Agent,实际 %v", out.MediaHeaders)
|
||||
}
|
||||
// 传输层头交给客户端 HTTP 栈,不能下发。
|
||||
if _, ok := out.MediaHeaders["Accept-Encoding"]; ok {
|
||||
t.Fatalf("不应下发 Accept-Encoding: %v", out.MediaHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaDirectAppliesToCachedChapter 缓存命中的章节同样要按本次请求决定通道。
|
||||
//
|
||||
// 缓存里存的是书源侧原始地址(改写前),若命中后不改写就会把上游地址直接
|
||||
// 漏给代理模式的客户端,表现为「图片/音频打不开」。
|
||||
func TestMediaDirectAppliesToCachedChapter(t *testing.T) {
|
||||
svc, _, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 首次读取写入缓存(直连模式)。
|
||||
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
|
||||
t.Fatalf("开启直连失败: %v", err)
|
||||
}
|
||||
first, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{MediaDirect: true})
|
||||
if err != nil || !first.MediaDirect {
|
||||
t.Fatalf("首次直连读取失败: %v", err)
|
||||
}
|
||||
|
||||
// 同章再按代理模式读:必须拿到签名代理地址,而不是缓存里的上游地址。
|
||||
second, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("二次读取失败: %v", err)
|
||||
}
|
||||
if second.MediaDirect {
|
||||
t.Fatal("代理模式不应标记直连")
|
||||
}
|
||||
if !strings.HasPrefix(second.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("缓存命中后仍应按代理改写,实际 %q", second.Tracks[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaDirectForceProxyWins 客户端显式要求代理时,即使书源开着直连也走代理。
|
||||
//
|
||||
// 这是直连取不到字节时的退路:不让用户为了继续读一章去改书源设置。
|
||||
func TestMediaDirectForceProxyWins(t *testing.T) {
|
||||
svc, _, book := prepareTicketBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := svc.SetSourceMediaDirect(ctx, readerTestUserID, sourceIDOf(t, svc, book.Origin), true); err != nil {
|
||||
t.Fatalf("开启直连失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0,
|
||||
ContentOptions{MediaDirect: true, ForceProxy: true})
|
||||
if err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
if out.MediaDirect {
|
||||
t.Fatal("强制代理时不应标记直连")
|
||||
}
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("强制代理时应返回签名代理地址,实际 %q", out.Tracks[0])
|
||||
}
|
||||
if len(out.MediaHeaders) != 0 {
|
||||
t.Fatalf("代理模式不应下发请求头,实际 %v", out.MediaHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
// sourceIDOf 按书源 URL 取当前用户的书源 ID。
|
||||
func sourceIDOf(t *testing.T, svc *ReaderService, origin string) string {
|
||||
t.Helper()
|
||||
srcs, err := svc.ListSources(t.Context(), readerTestUserID)
|
||||
if err != nil {
|
||||
t.Fatalf("列书源失败: %v", err)
|
||||
}
|
||||
for _, src := range srcs {
|
||||
if src.SourceURL == origin {
|
||||
return src.ID
|
||||
}
|
||||
}
|
||||
t.Fatalf("找不到书源 %s", origin)
|
||||
return ""
|
||||
}
|
||||
@@ -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, ContentOptions{})
|
||||
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, ContentOptions{})
|
||||
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, ContentOptions{})
|
||||
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, ContentOptions{}); 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, ContentOptions{})
|
||||
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("换票失败后旧缓存应已被作废,不应继续下发死票")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,12 +80,19 @@ func parseConcurrentRate(rate string) time.Duration {
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。
|
||||
// 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右;
|
||||
// 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。
|
||||
implicitRetryAttempts = 3
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// 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 +102,46 @@ func retryableStatus(code int) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// implicitRetryableStatus 书源没声明 retry 时,值得自动补一次的失败。
|
||||
//
|
||||
// 只覆盖「上游临时顶不住」的情形:429 是限流/并发上限,502/503/504 是网关或
|
||||
// 实例暂时不可用,歇一轮基本会恢复。403(风控)重试同一份请求多半还是 403,
|
||||
// 500 可能是源站自身 bug(确定性失败),都不在这里浪费一次往返。
|
||||
func implicitRetryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 429, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) == "" {
|
||||
@@ -405,12 +425,25 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
|
||||
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
|
||||
attempts := 1
|
||||
if req.Retry != nil && *req.Retry > 0 {
|
||||
declaredRetry := req.Retry != nil && *req.Retry > 0
|
||||
if declaredRetry {
|
||||
attempts += *req.Retry
|
||||
if attempts > maxRequestAttempts {
|
||||
attempts = maxRequestAttempts
|
||||
}
|
||||
}
|
||||
// 书源没声明 retry 时,GET 也对上游瞬时失败(429/5xx)自动补一次。
|
||||
// 很多聚合源跑在免费/低配后端上,限流是常态(懒人听书源实测约四成请求返回
|
||||
// HTTP 429),而 429 的响应体根本不是书源数据,交给规则只会解析出空目录、
|
||||
// 空详情。POST 可能有副作用,仍然只按书源声明重试。
|
||||
if !declaredRetry && (req.Method == "" || req.Method == http.MethodGet) {
|
||||
attempts = implicitRetryAttempts
|
||||
}
|
||||
// 自动补的一次只认「上游临时顶不住」的状态码,见 implicitRetryableStatus。
|
||||
retryAllowed := retryableStatus
|
||||
if !declaredRetry {
|
||||
retryAllowed = implicitRetryableStatus
|
||||
}
|
||||
var (
|
||||
resp *http.Response
|
||||
data []byte
|
||||
@@ -444,7 +477,7 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
|
||||
}
|
||||
return "", "", resp.StatusCode, err
|
||||
}
|
||||
if attempt < attempts && retryableStatus(resp.StatusCode) {
|
||||
if attempt < attempts && retryAllowed(resp.StatusCode) {
|
||||
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
|
||||
return "", "", resp.StatusCode, nil
|
||||
}
|
||||
@@ -516,6 +549,25 @@ func charsetFromContentType(ct string) string {
|
||||
return strings.Trim(cs, "\"")
|
||||
}
|
||||
|
||||
// upstreamStatusError 把书源接口的失败状态码翻译成可读错误。
|
||||
//
|
||||
// 上游限流/过载时返回的响应体不是书源数据:交给规则解析只会得到空目录、空详情,
|
||||
// 页面就表现成「目录 0 章」而没有任何报错。这里让调用方拿到明确错误,
|
||||
// 前端才能提示重试,而不是把「抓不到」当成「这本书没有章节」。
|
||||
func upstreamStatusError(code int) error {
|
||||
if code < 400 {
|
||||
return nil
|
||||
}
|
||||
switch code {
|
||||
case http.StatusTooManyRequests:
|
||||
return fmt.Errorf("书源接口限流(HTTP 429),请稍后重试")
|
||||
case http.StatusForbidden:
|
||||
return fmt.Errorf("书源接口拒绝访问(HTTP 403),可能触发了风控")
|
||||
default:
|
||||
return fmt.Errorf("书源接口返回 HTTP %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
|
||||
|
||||
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
|
||||
@@ -1111,10 +1163,13 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
body, finalURL, code, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upstreamStatusError(code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar := sess.newAnalyzer(key, page, body, finalURL)
|
||||
|
||||
elements, err := ar.GetElements(SPtr(sr.BookList))
|
||||
@@ -1144,7 +1199,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
|
||||
WordCount: wordCount,
|
||||
LatestChapter: lastChapter,
|
||||
Intro: intro,
|
||||
CoverURL: normalizeCoverURL(cover),
|
||||
CoverURL: normalizeCoverURL(cover, finalURL),
|
||||
BookURL: bookURL,
|
||||
Origins: []SearchOrigin{{
|
||||
SourceID: src.ID,
|
||||
@@ -1201,11 +1256,20 @@ func firstNonEmpty(vals ...string) string {
|
||||
// 于是封面上会落一串 data: 文本,<img> 按 text/plain 处理必然破图。
|
||||
// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
|
||||
// (/api/reader/local/asset?...)。
|
||||
func normalizeCoverURL(raw string) string {
|
||||
//
|
||||
// baseURL 是规则解析时的页面地址(详情页就是书本地址)。取值为空时规则会原样
|
||||
// 回退成它,回退结果同样不是图片:比如懒人听书源被上游限流(HTTP 429)时,
|
||||
// 详情 JSON 里没有 meta.cover,封面就会变成 .../lrts-book?bookId=xxx 这个接口地址。
|
||||
// 这类值必须一并丢掉,否则会被存进书架,之后一直显示破图。
|
||||
func normalizeCoverURL(raw, baseURL string) string {
|
||||
u := strings.TrimSpace(raw)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
// 规则取空后回退成页面地址的产物:不是图片,也不是用户要的封面。
|
||||
if base := strings.TrimSpace(baseURL); base != "" && u == base {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
|
||||
@@ -1262,10 +1326,13 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
body, finalURL, code, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upstreamStatusError(code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
sess.applyBookContext(ar, bookURL, book, "", 0)
|
||||
|
||||
@@ -1299,7 +1366,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
|
||||
info.Intro = v
|
||||
}
|
||||
if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" {
|
||||
info.CoverURL = normalizeCoverURL(v)
|
||||
info.CoverURL = normalizeCoverURL(v, finalURL)
|
||||
}
|
||||
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
|
||||
info.TocURL = v
|
||||
@@ -1549,6 +1616,37 @@ func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, b
|
||||
}
|
||||
}
|
||||
|
||||
// RepairShelfCover 用详情页刚解析出的封面修复书架记录。
|
||||
//
|
||||
// 只修「明显坏掉」的旧记录:封面为空,或等于书本地址/书源地址(规则取空后回退
|
||||
// baseUrl 的产物)。上游限流(HTTP 429)时详情里没有封面,旧版本会把接口地址
|
||||
// 存进书架,之后一直显示破图;打开一次详情页就能自愈。已有有效封面的记录不动,
|
||||
// 避免覆盖换源或用户数据。
|
||||
func (s *ReaderService) RepairShelfCover(ctx context.Context, userID, origin, bookURL, cover string) {
|
||||
if s.repo == nil || userID == "" || origin == "" || bookURL == "" {
|
||||
return
|
||||
}
|
||||
// 书架记录里的 BookURL 与调用方传入的一致(可能带 ",{...}" 选项段),
|
||||
// 查找用原值;只在校验封面是否为「回退地址」时用去掉选项段的形态。
|
||||
pageURL := tocBookTarget(bookURL)
|
||||
cover = normalizeCoverURL(cover, pageURL)
|
||||
if cover == "" {
|
||||
return
|
||||
}
|
||||
book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
|
||||
if err != nil || book == nil {
|
||||
return
|
||||
}
|
||||
current := strings.TrimSpace(book.CoverURL)
|
||||
if current != "" && current != pageURL && current != strings.TrimSpace(origin) {
|
||||
return
|
||||
}
|
||||
book.CoverURL = cover
|
||||
if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
|
||||
s.log.Warn("reader: 修复书架封面失败", zap.String("book", book.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// applyTocMeta 把目录阶段得到的信息写回书架记录:
|
||||
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
|
||||
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
|
||||
@@ -1675,10 +1773,13 @@ func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, b
|
||||
return nil, nil, -1, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
body, finalURL, code, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, nil, -1, err
|
||||
}
|
||||
if err := upstreamStatusError(code); err != nil {
|
||||
return nil, nil, -1, err
|
||||
}
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
sess.applyBookContext(ar, bookURL, nil, "", 0)
|
||||
|
||||
@@ -1749,6 +1850,13 @@ type ChapterContent struct {
|
||||
// 前端据此决定走 hls.js 而不是原生 <audio>:代理地址是 base64 编码的
|
||||
// /api/reader/media?...&u=...,前端无法再从 URL 后缀判断格式。
|
||||
IsHLS bool `json:"hls,omitempty"`
|
||||
// MediaDirect 为真表示 Tracks/Images 是上游原始地址,由客户端直接去取,
|
||||
// 服务端不再代理字节。此时 MediaHeaders 给出取这些地址所需的请求头。
|
||||
// 只有客户端显式请求直连(ContentOptions.MediaDirect)且书源开启该开关时才会出现。
|
||||
MediaDirect bool `json:"media_direct,omitempty"`
|
||||
// MediaHeaders 直连模式下取媒体所需的请求头(防盗链 Referer/UA、登录 Cookie 等)。
|
||||
// 代理模式下为空:请求头只在服务端内部使用,不下发给客户端。
|
||||
MediaHeaders map[string]string `json:"media_headers,omitempty"`
|
||||
// declaredType 书源在规则 JS 里声明的书籍类型(-1 表示未声明),
|
||||
// 由 GetContentForBook 写回书架记录(对应 legado Book.type)。
|
||||
declaredType int
|
||||
@@ -1756,6 +1864,106 @@ type ChapterContent struct {
|
||||
|
||||
// ─── 媒体代理(音频流 / 漫画图片,带防盗链头与 HMAC 签名) ──────────────────
|
||||
|
||||
// ContentOptions 正文下发的可选行为,只由请求参数决定,不改变默认链路。
|
||||
//
|
||||
// 默认零值 = 今天的网页行为:媒体地址全部改写为服务端签名代理,由服务端
|
||||
// 拉取字节再转发。MediaDirect 让客户端(Android App)自己去取上游字节,
|
||||
// 服务端只负责解析地址与下发请求头。
|
||||
type ContentOptions struct {
|
||||
// MediaDirect 请求直连媒体字节。仅对开启了 MediaDirect 的书源生效,
|
||||
// 否则仍返回代理地址,避免把未验证的源推到客户端直连。
|
||||
MediaDirect bool
|
||||
// ForceProxy 本次强制走服务端代理,即使书源开着直连。
|
||||
//
|
||||
// 给客户端留的退路:直连取不到(防盗链、票失效、上游限流等)时可以要求
|
||||
// 服务端先代理一次,而不是让用户去改书源设置。
|
||||
ForceProxy bool
|
||||
}
|
||||
|
||||
// mediaDirectFor 结合书源开关与本次请求选项,决定这一章是否直连。
|
||||
func (s *ReaderService) mediaDirectFor(ctx context.Context, origin string, opts ContentOptions) bool {
|
||||
if !opts.MediaDirect || opts.ForceProxy {
|
||||
return false
|
||||
}
|
||||
return s.sourceAllowsMediaDirect(ctx, origin)
|
||||
}
|
||||
|
||||
// applyMediaURLs 决定音频/图片地址是代理还是直连,并填好直连所需请求头。
|
||||
//
|
||||
// 直连时保留上游原始地址,同时把这一章的请求头一次性下发:同一章内的音轨与
|
||||
// 图片通常共用书源级 header / 登录态,逐条下发会让响应体成倍变大。
|
||||
// 逐图选项段(",{headers:...}")的差异不在这里展开 —— 客户端会自行拆分。
|
||||
func (s *ReaderService) applyMediaURLs(ctx context.Context, book *model.ReaderBook, out *ChapterContent, direct bool) {
|
||||
if out == nil {
|
||||
return
|
||||
}
|
||||
if !direct {
|
||||
for i, t := range out.Tracks {
|
||||
out.Tracks[i] = s.ProxyURL(book.ID, t)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
out.Images[i] = s.ProxyURL(book.ID, img)
|
||||
}
|
||||
return
|
||||
}
|
||||
out.MediaDirect = true
|
||||
if out.MediaHeaders == nil {
|
||||
out.MediaHeaders = map[string]string{}
|
||||
}
|
||||
// 请求头按章节内第一条媒体地址计算:同一章的音轨(或图片)通常同域名、
|
||||
// 共用书源级 header 与登录态。逐图选项段(",{headers:...}")的差异留给
|
||||
// 客户端自行拆分,不下发也不参与这里的计算。
|
||||
sample := ""
|
||||
if len(out.Tracks) > 0 {
|
||||
sample = out.Tracks[0]
|
||||
} else if len(out.Images) > 0 {
|
||||
sample = out.Images[0]
|
||||
}
|
||||
if sample != "" {
|
||||
base, options, _ := rule.ParseMediaOptions(sample)
|
||||
for k, v := range s.MediaRequestHeaders(ctx, book, base, options.Headers) {
|
||||
// 传输层头由客户端 HTTP 栈自己算,下发了反而可能冲突。
|
||||
if transportHeader(k) {
|
||||
continue
|
||||
}
|
||||
out.MediaHeaders[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sourceAllowsMediaDirect 书源是否允许客户端直连取媒体(按书源 URL 查当前用户的副本)。
|
||||
//
|
||||
// 查不到书源时返回 false:宁可退回代理,也不把未确认归属的源推到客户端直连。
|
||||
func (s *ReaderService) sourceAllowsMediaDirect(ctx context.Context, origin string) bool {
|
||||
if s == nil || s.repo == nil || strings.TrimSpace(origin) == "" {
|
||||
return false
|
||||
}
|
||||
src, err := s.repo.GetSourceAnyByURL(ctx, origin)
|
||||
if err != nil || src == nil {
|
||||
return false
|
||||
}
|
||||
return src.MediaDirect
|
||||
}
|
||||
|
||||
// SetSourceMediaDirect 切换书源的媒体直连开关(限当前用户自己的书源)。
|
||||
func (s *ReaderService) SetSourceMediaDirect(ctx context.Context, userID, id string, direct bool) error {
|
||||
src, err := s.repo.GetSourceForUser(ctx, userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src.MediaDirect = direct
|
||||
return s.repo.UpdateSource(ctx, src)
|
||||
}
|
||||
|
||||
// transportHeader 判断是否是传输层头(由客户端 HTTP 栈负责,不应下发)。
|
||||
func transportHeader(name string) bool {
|
||||
switch strings.ToLower(name) {
|
||||
case "accept-encoding", "connection", "host", "content-length", "cookie2":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProxyURL 将书源返回的媒体地址改写为签名代理地址。
|
||||
// 签名 = HMAC-SHA256(jwtSecret, bookID|url),防止代理被滥用为开放中转。
|
||||
//
|
||||
@@ -1801,7 +2009,7 @@ func (s *ReaderService) ProxyCoverURL(id, rawURL string) string {
|
||||
//
|
||||
// id 为空时按书源 URL 签(搜索结果);传入书籍 ID 时按书签。
|
||||
func (s *ReaderService) RewriteBookCover(ctx context.Context, id, origin, coverURL string) string {
|
||||
cover := normalizeCoverURL(coverURL)
|
||||
cover := normalizeCoverURL(coverURL, "")
|
||||
if cover == "" || !strings.HasPrefix(cover, "http") {
|
||||
return cover
|
||||
}
|
||||
@@ -1844,6 +2052,80 @@ func BodyIsPlaylist(head []byte, contentType string) bool {
|
||||
return strings.Contains(contentType, "mpegurl") && strings.Contains(string(head), "#EXTM3U")
|
||||
}
|
||||
|
||||
// MediaRequestHeaders 构造取上游媒体所需的完整请求头。
|
||||
//
|
||||
// 服务端拉流(FetchMediaWithOptions)与客户端直连(ContentOptions.MediaDirect)
|
||||
// 共用这一份逻辑,保证两条链路的防盗链头、登录态与书源头完全一致 —— 直连只是
|
||||
// 把「谁发起 HTTP 请求」从服务端换成 App,不应退化成一套更弱的请求。
|
||||
//
|
||||
// 优先级由低到高:通用预设 < 书源 header < 登录态 < URL 尾部选项。
|
||||
func (s *ReaderService) MediaRequestHeaders(ctx context.Context, book *model.ReaderBook, rawURL string, options map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
out[k] = v
|
||||
}
|
||||
if book == nil || s == nil {
|
||||
return out
|
||||
}
|
||||
// 书源级请求头
|
||||
if s.repo != nil {
|
||||
if found, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(found.Header), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
out[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
|
||||
if s.repo != nil {
|
||||
state := s.newSourceState(ctx, book.Origin)
|
||||
for k, v := range state.LoginHeaderMap() {
|
||||
if strings.EqualFold(k, "cookie") {
|
||||
continue
|
||||
}
|
||||
if out[k] == "" {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if out["Cookie"] == "" {
|
||||
if ck := state.CookieForRequest(rawURL); ck != "" {
|
||||
out["Cookie"] = ck
|
||||
}
|
||||
}
|
||||
}
|
||||
// URL 尾部选项里的请求头:优先级最高(逐图声明,取图必需)。
|
||||
optionCookie := ""
|
||||
for k, v := range options {
|
||||
if strings.EqualFold(k, "Cookie") {
|
||||
optionCookie = v
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if optionCookie != "" {
|
||||
// 与源 Cookie 合并时 options 优先(对应 legado AnalyzeUrl.setCookie 的临时 Cookie)。
|
||||
if base := out["Cookie"]; base != "" {
|
||||
out["Cookie"] = rule.MergeCookie(base, optionCookie)
|
||||
} else {
|
||||
out["Cookie"] = optionCookie
|
||||
}
|
||||
}
|
||||
// 默认 Referer 只能用「真正的 http(s) 书源地址」。
|
||||
// legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」)
|
||||
// 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并
|
||||
// 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。
|
||||
// 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header
|
||||
// 里自己声明的 Referer 优先级更高,不受这里影响。
|
||||
if out["Referer"] == "" {
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
out["Referer"] = referer
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchMedia 用书源会话拉取媒体(不应用 URL 尾部选项)。
|
||||
func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) {
|
||||
return s.FetchMediaWithOptions(ctx, book, rawURL, rangeHeader, nil)
|
||||
@@ -1861,66 +2143,13 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range helper.HTTPHeaderPresets() {
|
||||
for k, v := range s.MediaRequestHeaders(ctx, book, rawURL, options) {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
// 媒体流同样交给 net/http 管压缩:否则压缩过的资源会以原始字节透传给
|
||||
// 播放器/图片标签,表现为「打不开」。Range 请求服务端通常不压缩,
|
||||
// 解压后 resp 会去掉 Content-Length/Content-Encoding,透传逻辑不受影响。
|
||||
helper.StripAcceptEncoding(httpReq.Header)
|
||||
// 书源级请求头
|
||||
if s.repo != nil {
|
||||
if found, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(found.Header), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 登录态:登录类书源的漫画/音频资源同样需要 Cookie 与 loginHeader 才能取到。
|
||||
if s.repo != nil {
|
||||
state := s.newSourceState(ctx, book.Origin)
|
||||
for k, v := range state.LoginHeaderMap() {
|
||||
if !strings.EqualFold(k, "cookie") && httpReq.Header.Get(k) == "" {
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
if httpReq.Header.Get("Cookie") == "" {
|
||||
if ck := state.CookieForRequest(rawURL); ck != "" {
|
||||
httpReq.Header.Set("Cookie", ck)
|
||||
}
|
||||
}
|
||||
}
|
||||
// URL 尾部选项里的请求头:优先级最高(逐图声明,取图必需)。
|
||||
optionCookie := ""
|
||||
for k, v := range options {
|
||||
if strings.EqualFold(k, "Cookie") {
|
||||
optionCookie = v
|
||||
continue
|
||||
}
|
||||
httpReq.Header.Set(k, v)
|
||||
}
|
||||
if optionCookie != "" {
|
||||
// 与源 Cookie 合并时 options 优先(对应 legado AnalyzeUrl.setCookie 的临时 Cookie)。
|
||||
if base := httpReq.Header.Get("Cookie"); base != "" {
|
||||
httpReq.Header.Set("Cookie", rule.MergeCookie(base, optionCookie))
|
||||
} else {
|
||||
httpReq.Header.Set("Cookie", optionCookie)
|
||||
}
|
||||
}
|
||||
// 默认 Referer 只能用「真正的 http(s) 书源地址」。
|
||||
// legado 的 origin 对普通书源是 bookSourceUrl,但聚合类书源(如「光遇聚合」)
|
||||
// 的 origin 是个显示名,拼出来的 Referer 非法,会被图床判定为盗链并
|
||||
// 301 到一张「请到本网站阅读」的占位图 —— 表现为漫画每一页都是同一张提示图。
|
||||
// 这种情况下宁可不发 Referer(实测不带 Referer 能拿到原图);书源 header
|
||||
// 里自己声明的 Referer 优先级更高,不受这里影响。
|
||||
if httpReq.Header.Get("Referer") == "" {
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
httpReq.Header.Set("Referer", referer)
|
||||
}
|
||||
}
|
||||
if rangeHeader != "" {
|
||||
httpReq.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
@@ -1942,12 +2171,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
|
||||
}
|
||||
// 可重试的状态码:读完并关闭响应体再试,避免连接泄漏。
|
||||
@@ -2060,10 +2291,13 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
|
||||
return nil, req.Unsupported
|
||||
}
|
||||
sess.applyHeaders(req, runner)
|
||||
body, finalURL, _, err := sess.fetch(req)
|
||||
body, finalURL, code, err := sess.fetch(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := upstreamStatusError(code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastFinalURL = finalURL
|
||||
ar := sess.newAnalyzer("", 0, body, finalURL)
|
||||
sess.applyBookContext(ar, bookURL, sess.book, "", 0)
|
||||
@@ -2511,7 +2745,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)
|
||||
}
|
||||
|
||||
// ─── 书架 ──────────────────────────────────────────────────────────────────
|
||||
@@ -2528,7 +2762,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
|
||||
BookURL: origin.BookURL,
|
||||
Name: name,
|
||||
Author: author,
|
||||
CoverURL: normalizeCoverURL(coverURL),
|
||||
CoverURL: normalizeCoverURL(coverURL, origin.BookURL),
|
||||
Type: origin.OriginType,
|
||||
}
|
||||
if err := s.repo.CreateBook(ctx, book); err != nil {
|
||||
@@ -2589,7 +2823,7 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
|
||||
if info != nil {
|
||||
book.TocURL = strings.TrimSpace(info.TocURL)
|
||||
if book.CoverURL == "" {
|
||||
book.CoverURL = normalizeCoverURL(info.CoverURL)
|
||||
book.CoverURL = normalizeCoverURL(info.CoverURL, target)
|
||||
}
|
||||
if info.LatestChapter != "" {
|
||||
book.LatestChapterTitle = info.LatestChapter
|
||||
@@ -3002,7 +3236,9 @@ func (s *ReaderService) DeleteReplaceRule(ctx context.Context, userID, id string
|
||||
// GetContentForBook 按书架书籍 + 章节序号取正文:
|
||||
// 解析书源 → 持久缓存(命中直接返回)→ 抓正文(单飞)→ 写缓存 →
|
||||
// 书源 replaceRegex → 用户替换净化规则 → 签名代理改写。
|
||||
func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID string, chapterIndex int) (*ChapterContent, error) {
|
||||
//
|
||||
// opts 为零值时行为与历史版本完全一致(媒体全部走服务端代理)。
|
||||
func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID string, chapterIndex int, opts ContentOptions) (*ChapterContent, error) {
|
||||
book, err := s.repo.GetBook(ctx, bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -3046,22 +3282,21 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
if out.Type == "text" {
|
||||
out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content)
|
||||
}
|
||||
// 音频/图片地址改写为签名代理(浏览器播放/展示无法带防盗链头)
|
||||
for i, t := range out.Tracks {
|
||||
out.Tracks[i] = s.ProxyURL(book.ID, t)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
out.Images[i] = s.ProxyURL(book.ID, img)
|
||||
}
|
||||
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记(同样走签名代理,
|
||||
// 这样需要防盗链头的图片也能显示)。见 rewriteContentImageMarkers 的说明。
|
||||
direct := s.mediaDirectFor(ctx, book.Origin, opts)
|
||||
// 文本型漫画源:正文里的 <img> 标签换成前端认识的 [img] 标记。
|
||||
// 默认仍走签名代理,这样需要防盗链头的图片也能显示(见
|
||||
// rewriteContentImageMarkers 的说明);直连时保留上游原始地址。
|
||||
if out.Type == "text" && strings.Contains(out.Content, "<img") {
|
||||
out.Content = rewriteContentImageMarkers(out.Content, ch.URL, func(u string) string {
|
||||
if direct {
|
||||
return u
|
||||
}
|
||||
return s.ProxyURL(book.ID, u)
|
||||
})
|
||||
}
|
||||
// 整章都是 [img] 标记 → 这就是一章漫画,改成图片类型交给漫画阅读器。
|
||||
// 若还按文本下发,前端会把它当文字按列分页,一屏只有一张图(双页铺开也无从谈起)。
|
||||
// 必须早于 applyMediaURLs:转换出来的图片地址同样要参与代理/直连决策与请求头计算。
|
||||
if out.Type == "text" {
|
||||
if imgs, ok := imageMarkersOnly(out.Content); ok {
|
||||
out.Type = "image"
|
||||
@@ -3069,6 +3304,9 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
||||
out.Content = ""
|
||||
}
|
||||
}
|
||||
// 音频/图片地址改写:默认走签名代理(浏览器播放/展示无法带防盗链头);
|
||||
// 客户端显式请求直连且书源允许时,保留上游地址并下发所需请求头。
|
||||
s.applyMediaURLs(ctx, book, out, direct)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -3154,6 +3392,9 @@ type ContentBatchItem struct {
|
||||
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
|
||||
Cached bool `json:"cached"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// MediaDirect / MediaHeaders 同 ChapterContent:客户端直连时的地址与请求头。
|
||||
MediaDirect bool `json:"media_direct,omitempty"`
|
||||
MediaHeaders map[string]string `json:"media_headers,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBatchResult 批量取正文的汇总结果。
|
||||
@@ -3172,7 +3413,7 @@ const contentBatchConcurrency = 4
|
||||
// GetContentBatch 批量取正文(对应 legado 的 CacheBook 离线缓存协议)。
|
||||
//
|
||||
// 单章失败不中断整批:失败项带 error,其余照常返回。已缓存的章节不访问书源。
|
||||
func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID string, indexes []int, applyReplace bool) (*ContentBatchResult, error) {
|
||||
func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID string, indexes []int, applyReplace bool, opts ContentOptions) (*ContentBatchResult, error) {
|
||||
book, err := s.repo.GetBook(ctx, bookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -3208,7 +3449,7 @@ func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID stri
|
||||
result.Items[pos] = ContentBatchItem{ChapterIndex: idx, Error: ctx.Err().Error()}
|
||||
return
|
||||
}
|
||||
item, cached := s.loadBatchItem(ctx, userID, book, chapters[idx], applyReplace)
|
||||
item, cached := s.loadBatchItem(ctx, userID, book, chapters[idx], applyReplace, opts)
|
||||
result.Items[pos] = item
|
||||
if item.Error != "" {
|
||||
result.Stats.Failed++
|
||||
@@ -3224,18 +3465,22 @@ func (s *ReaderService) GetContentBatch(ctx context.Context, userID, bookID stri
|
||||
}
|
||||
|
||||
// loadBatchItem 取单章(批量接口用):先查缓存(不访问书源),再走完整链路。
|
||||
func (s *ReaderService) loadBatchItem(ctx context.Context, userID string, book *model.ReaderBook, ch model.ReaderChapter, applyReplace bool) (ContentBatchItem, bool) {
|
||||
func (s *ReaderService) loadBatchItem(ctx context.Context, userID string, book *model.ReaderBook, ch model.ReaderChapter, applyReplace bool, opts ContentOptions) (ContentBatchItem, bool) {
|
||||
item := ContentBatchItem{ChapterIndex: ch.Index}
|
||||
src, bs, loadErr := s.loadSourceFlexible(ctx, "", book.Origin)
|
||||
cached := false
|
||||
if loadErr == nil {
|
||||
if out, ok := s.loadCachedContent(ctx, src, bs, book, ch, ""); ok {
|
||||
// 缓存里存的是书源侧原始地址(改写前),命中后同样要按本次请求
|
||||
// 决定代理/直连,否则缓存命中的章节会绕过媒体通道选择。
|
||||
direct := s.mediaDirectFor(ctx, book.Origin, opts)
|
||||
s.applyMediaURLs(ctx, book, out, direct)
|
||||
item = batchItemFromContent(ch.Index, out, applyReplace, userID, book, s)
|
||||
cached = true
|
||||
}
|
||||
}
|
||||
if !cached {
|
||||
out, err := s.GetContentForBook(ctx, userID, book.ID, ch.Index)
|
||||
out, err := s.GetContentForBook(ctx, userID, book.ID, ch.Index, opts)
|
||||
if err != nil {
|
||||
item.Error = err.Error()
|
||||
return item, false
|
||||
@@ -3256,6 +3501,8 @@ func batchItemFromContent(index int, out *ChapterContent, applyReplace bool, use
|
||||
ImageStyle: out.ImageStyle,
|
||||
IsHLS: out.IsHLS,
|
||||
Comments: out.Comments,
|
||||
MediaDirect: out.MediaDirect,
|
||||
MediaHeaders: out.MediaHeaders,
|
||||
}
|
||||
if applyReplace && out.Type == "text" && s != nil && book != nil {
|
||||
item.Content = s.applyUserReplaceRules(context.Background(), userID, book.Name, item.Content)
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestContentCacheHitAndPersist(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestContentCacheHitAndPersist(t *testing.T) {
|
||||
t.Fatalf("首次读取应访问书源 1 次,实际 %d", got)
|
||||
}
|
||||
|
||||
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("二次读取失败: %v", err)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestContentCacheSurvivesTocRefresh(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
|
||||
t.Fatalf("读取第 2 章失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestContentCacheSurvivesTocRefresh(t *testing.T) {
|
||||
newChapters, _ := svc.repo.ListChapters(ctx, book.ID)
|
||||
svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters)
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
|
||||
t.Fatalf("刷新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 1 {
|
||||
@@ -209,7 +209,7 @@ func TestContentCacheSingleFlight(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -229,14 +229,14 @@ func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
// 预热第 1 章(进缓存),第 2 章按失败处理。
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
|
||||
t.Fatalf("预热失败: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = true
|
||||
srv.mu.Unlock()
|
||||
|
||||
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false)
|
||||
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("批量读取失败: %v", err)
|
||||
}
|
||||
@@ -262,14 +262,14 @@ func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = false
|
||||
srv.mu.Unlock()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1, ContentOptions{}); err != nil {
|
||||
t.Fatalf("恢复后读取第 2 章失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 2 {
|
||||
t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got)
|
||||
}
|
||||
// 越界/卷章节被忽略。
|
||||
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 {
|
||||
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false, ContentOptions{}); err != nil || len(got.Items) != 0 {
|
||||
t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err)
|
||||
}
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
|
||||
@@ -287,7 +287,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, "u1", updated); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
|
||||
t.Fatalf("更新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(1); got != 2 {
|
||||
@@ -299,7 +299,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
func TestContentCacheClearedOnRemoveBook(t *testing.T) {
|
||||
svc, _, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{}); err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
sourceID := svc.contentSourceID(ctx, nil, book)
|
||||
@@ -395,7 +395,7 @@ func TestContentCacheWithoutCacheDir(t *testing.T) {
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0, ContentOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("无缓存目录时读取失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestEndToEndSourceChain(t *testing.T) {
|
||||
func TestGetContentForBookEmptyContentFails(t *testing.T) {
|
||||
svc, bookID := setupTextChapterBook(t, "")
|
||||
|
||||
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0, ContentOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("空正文应当报错,实际当成功下发了")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 上游限流(HTTP 429)在免费/低配后端上是常态。书源没声明 retry 时,
|
||||
// GET 也会自动补一次:第一次 429、第二次正常时,详情必须能拿到数据。
|
||||
func TestGetBookInfoRetriesUpstream429(t *testing.T) {
|
||||
upstream := e2eServer()
|
||||
defer upstream.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
hits := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
hits[r.URL.Path]++
|
||||
n := hits[r.URL.Path]
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
|
||||
return
|
||||
}
|
||||
resp, err := http.Get(upstream.URL + r.URL.RequestURI())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, _ = w.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
|
||||
if err != nil {
|
||||
t.Fatalf("第一次 429、重试后应成功: %v", err)
|
||||
}
|
||||
if info.Name == "" {
|
||||
t.Fatalf("重试后详情应有书名,实际为空")
|
||||
}
|
||||
}
|
||||
|
||||
// 持续限流时必须报错,而不是把 429 的响应体解析成「空目录 / 0 章」。
|
||||
func TestGetTocReportsUpstream429(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
_, err := svc.GetToc(t.Context(), "u1", sourceID, srv.URL, srv.URL+"/book/1", srv.URL+"/book/1")
|
||||
if err == nil {
|
||||
t.Fatalf("持续 429 时应返回错误,而不是空目录")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") {
|
||||
t.Fatalf("错误信息应包含 429,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
// organize_source opt-in — organize the configured staging folder.
|
||||
// transcode_cleanup every 24 h — purge HLS transcode artefacts
|
||||
// older than 24 h.
|
||||
// cache_cleanup every 1 h — enforce the global disk-cache capacity
|
||||
// (hls → images → library-covers; reader-audio
|
||||
// has its own 1 GB eviction on write).
|
||||
// segment_prewarm every 6 h — fill IntroDB skip segments for queryable media.
|
||||
//
|
||||
// Each job runs at most once at a time (an in-flight run blocks the
|
||||
@@ -41,7 +44,7 @@ type SchedulerService struct {
|
||||
cacheDir string
|
||||
now func() time.Time
|
||||
|
||||
imagesPolicyProvider func() ImageCachePolicy
|
||||
cachePolicyProvider func() CacheCleanupPolicy
|
||||
|
||||
// readerContentCleaner 阅读正文缓存的清理钩子(由阅读模块注入)。
|
||||
readerContentCleaner func(context.Context)
|
||||
@@ -79,22 +82,24 @@ func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) {
|
||||
s.expiryWatcher = watcher
|
||||
}
|
||||
|
||||
// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。
|
||||
// CacheCleanupPolicy 是一次全缓存容量清理要用的策略(全部为 0 表示不做任何清理)。
|
||||
// TotalBytes 是 hls / images / library-covers 三个池的总容量上限;OriginalsBytes
|
||||
// 是 images 原图池的独立子配额。reader-audio 由自身 1GB 配额管理,不在此列。
|
||||
// 只有容量配额,没有保留时长:淘汰只看占用,不按文件年龄删除。
|
||||
type ImageCachePolicy struct {
|
||||
type CacheCleanupPolicy struct {
|
||||
TotalBytes int64
|
||||
OriginalsBytes int64
|
||||
}
|
||||
|
||||
func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) {
|
||||
s.imagesPolicyProvider = fn
|
||||
func (s *SchedulerService) SetCacheCleanupPolicyProvider(fn func() CacheCleanupPolicy) {
|
||||
s.cachePolicyProvider = fn
|
||||
}
|
||||
|
||||
func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
|
||||
if s.imagesPolicyProvider != nil {
|
||||
return s.imagesPolicyProvider()
|
||||
func (s *SchedulerService) cacheCleanupPolicy() CacheCleanupPolicy {
|
||||
if s.cachePolicyProvider != nil {
|
||||
return s.cachePolicyProvider()
|
||||
}
|
||||
return ImageCachePolicy{}
|
||||
return CacheCleanupPolicy{}
|
||||
}
|
||||
|
||||
// SetReaderContentCleaner 注入阅读正文缓存的清理钩子(TTL 过期 + 容量 LRU)。
|
||||
@@ -166,9 +171,9 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobCleanTranscodeCache,
|
||||
},
|
||||
{
|
||||
name: "image_cache_cleanup",
|
||||
name: "cache_cleanup",
|
||||
interval: 1 * time.Hour,
|
||||
run: s.jobCleanImageCache,
|
||||
run: s.jobCleanCache,
|
||||
},
|
||||
}
|
||||
// 阅读正文缓存清理只在注入钩子后注册(见 SetReaderContentCleaner)。
|
||||
|
||||
@@ -299,30 +299,33 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
return walkAndPrune(s.cacheDir+"/hls", cutoff)
|
||||
}
|
||||
|
||||
// jobCleanImageCache prunes the image proxy cache: 原图按独立配额优先淘汰,
|
||||
// 总量超限时再淘汰派生成品。全程只看占用,不按文件年龄删除。
|
||||
func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error {
|
||||
// jobCleanCache enforces the global disk-cache capacity: when the total size of
|
||||
// hls + images + library-covers exceeds the configured limit, the pools are
|
||||
// evicted strictly in that order (oldest file first within each pool) until
|
||||
// usage falls back to 80% of the limit. reader-audio is not part of the total;
|
||||
// it has its own 1 GB eviction on write. The images original sub-quota is
|
||||
// enforced afterwards as an independent constraint.
|
||||
func (s *SchedulerService) jobCleanCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
return nil
|
||||
}
|
||||
policy := s.imageCachePolicy()
|
||||
policy := s.cacheCleanupPolicy()
|
||||
if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
imagesDir := filepath.Join(s.cacheDir, "images")
|
||||
pools := ImageCachePools(imagesDir, policy.OriginalsBytes)
|
||||
res, err := PruneImageCachePools(pools, policy.TotalBytes)
|
||||
res, err := PruneCache(s.cacheDir, policy.TotalBytes, policy.OriginalsBytes)
|
||||
if err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("scheduled image cache cleanup failed", zap.Error(err))
|
||||
s.log.Warn("scheduled cache cleanup failed", zap.Error(err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if res.DeletedFiles > 0 && s.log != nil {
|
||||
s.log.Info("scheduled image cache cleanup completed",
|
||||
s.log.Info("scheduled cache cleanup completed",
|
||||
zap.Int("deleted_files", res.DeletedFiles),
|
||||
zap.Int64("freed_bytes", res.FreedBytes),
|
||||
zap.Int64("remaining_bytes", res.RemainingBytes),
|
||||
zap.Any("freed_by_pool", res.FreedByPool),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -217,11 +217,11 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
b.c.Scheduler.SetBackupService(b.c.Backup)
|
||||
b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline)
|
||||
b.c.Scheduler.SetSegments(b.c.Segments)
|
||||
b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy {
|
||||
b.c.Scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy {
|
||||
if b.cfg == nil {
|
||||
return ImageCachePolicy{}
|
||||
return CacheCleanupPolicy{}
|
||||
}
|
||||
policy := ImageCachePolicy{
|
||||
policy := CacheCleanupPolicy{
|
||||
TotalBytes: int64(b.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024,
|
||||
}
|
||||
if b.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 {
|
||||
|
||||
@@ -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
|
||||
/** 播放中控制栏无操作自动隐藏的延时。 */
|
||||
@@ -292,6 +301,8 @@ export function PlayerControls({
|
||||
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)
|
||||
@@ -493,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) {
|
||||
@@ -545,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)
|
||||
@@ -567,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)
|
||||
@@ -638,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)
|
||||
@@ -731,6 +764,8 @@ export function PlayerControls({
|
||||
}
|
||||
|
||||
pendingSeekRef.current = target
|
||||
// 方向键也是一次真实 seek:播放事件在抑制窗口内不得唤出控制栏。
|
||||
lastSeekActivityRef.current = Date.now()
|
||||
setCurrentTime(target)
|
||||
setSeekHint({
|
||||
dir: applied < 0 ? 'back' : 'forward',
|
||||
|
||||
@@ -139,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)
|
||||
@@ -173,6 +177,11 @@ export function PlayerPage() {
|
||||
const [mode, setMode] = useState<PlayerMode>('direct')
|
||||
const diagnosticModeRef = useRef(mode)
|
||||
useEffect(() => { diagnosticModeRef.current = mode }, [mode])
|
||||
// modeRef / mediaRef 会被声明点之上的回调读到(handleDirectSeekStalled、
|
||||
// fallbackDirectPlay、toggleVr360 等),所以先在这里声明,赋值仍由下方各自的
|
||||
// 同步 effect 负责;否则以后有人在渲染期调用这些回调就会踩 TDZ。
|
||||
const modeRef = useRef(mode)
|
||||
const mediaRef = useRef(media)
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false)
|
||||
const [diagnosticEvents, setDiagnosticEvents] = useState<PlayerDiagnosticEvent[]>([])
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
@@ -198,6 +207,12 @@ export function PlayerPage() {
|
||||
const subtitlePreferenceTouchedRef = useRef(false)
|
||||
const subtitlePreferenceSaveQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
// 播放接线 effect 里只有一条提示需要读它,用 ref 读即可保持最新;如果放进依赖,
|
||||
// 这个 effect 会因为自己写出来的状态而整体重建(teardownHls + DELETE 转码任务)。
|
||||
const hlsUnavailableRef = useRef(false)
|
||||
useEffect(() => {
|
||||
hlsUnavailableRef.current = hlsUnavailable
|
||||
}, [hlsUnavailable])
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
|
||||
const [loadError, setLoadError] = useState('')
|
||||
@@ -538,6 +553,12 @@ export function PlayerPage() {
|
||||
setPlaybackInfo(null)
|
||||
setHlsSource('local')
|
||||
setSelectedQuality('')
|
||||
// HLS 可用性与字幕列表都是「上一条媒体」得出的结论,必须跟着媒体一起清掉:
|
||||
// 否则某一集 HLS 失败一次,之后每集直连失败就再也不会回退转码(只弹「HLS 不可用」);
|
||||
// 字幕列表留着旧条目还会用新 media.id 拼旧 path 去请求一次。
|
||||
setHlsUnavailable(false)
|
||||
setSubs([])
|
||||
setSubtitleIndex(-1)
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setCloudWaitStartedAt(0)
|
||||
@@ -556,6 +577,7 @@ export function PlayerPage() {
|
||||
setDanmakuInfo(null)
|
||||
setDanmakuSearching(true)
|
||||
directRetryRef.current = false
|
||||
directViaProxyRef.current = false
|
||||
retryingDirectRef.current = false
|
||||
directSeekGraceUntilRef.current = 0
|
||||
transientPlayErrorRef.current = ''
|
||||
@@ -1253,7 +1275,6 @@ export function PlayerPage() {
|
||||
// 作为播放 effect 的依赖,会在 STRM 的 302 直链仍在建立时重复设置 src,
|
||||
// Chromium 会把被中断的首次加载报告成播放错误并误触发 HLS 回退。
|
||||
const activeBurnedSubtitleStream = mode === 'hls' ? burnedSubtitleStream : undefined
|
||||
const mediaRef = useRef(media)
|
||||
useEffect(() => {
|
||||
mediaRef.current = media
|
||||
directOnlyRef.current = directOnly
|
||||
@@ -1485,7 +1506,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 的中断。
|
||||
@@ -1521,7 +1542,7 @@ export function PlayerPage() {
|
||||
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
|
||||
armDirectStallWatchdog()
|
||||
}
|
||||
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
|
||||
if (hlsUnavailableRef.current && needsTranscodeForBrowser(currentMedia)) {
|
||||
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
|
||||
}
|
||||
}
|
||||
@@ -1550,7 +1571,6 @@ export function PlayerPage() {
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
hlsUnavailable,
|
||||
hlsStartSec,
|
||||
mediaId,
|
||||
mode,
|
||||
@@ -1585,22 +1605,39 @@ export function PlayerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumePosition || initialSeekDone) return
|
||||
let cancelled = false
|
||||
if (mode === 'hls' && hlsSource === 'local') {
|
||||
// Restart transcode near the resume point instead of seeking a short partial playlist.
|
||||
if (Math.abs(hlsStartSec - resumePosition) > 2) {
|
||||
setHlsStartSec(resumePosition)
|
||||
}
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
|
||||
return
|
||||
// 本地 HLS 的播放列表 t=0 就是 resumePosition,位置由转码起点决定;但清单还没
|
||||
// 回来、转码也可能直接失败,此刻同步弹「已恢复」就是一句空承诺。等画面真的出
|
||||
// 数据(loadeddata)再报,和下面直连分支的行为保持一致。
|
||||
const hlsVideo = ref.current
|
||||
if (!hlsVideo) return
|
||||
const confirmResume = () => {
|
||||
if (cancelled) return
|
||||
const el = ref.current
|
||||
if (!el || el.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return
|
||||
setInitialSeekDone(true)
|
||||
const m = Math.floor(resumePosition / 60)
|
||||
const s = Math.floor(resumePosition % 60)
|
||||
toast.success(`已恢复上次播放进度至 ${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`, { duration: 2500 })
|
||||
}
|
||||
if (hlsVideo.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||
confirmResume()
|
||||
return
|
||||
}
|
||||
hlsVideo.addEventListener('loadeddata', confirmResume, { once: true })
|
||||
return () => {
|
||||
cancelled = true
|
||||
hlsVideo.removeEventListener('loadeddata', confirmResume)
|
||||
}
|
||||
}
|
||||
const video = ref.current
|
||||
if (!video) return
|
||||
|
||||
let cancelled = false
|
||||
let seekStarted = false
|
||||
let onPlaying: (() => void) | undefined
|
||||
let onCanPlay: (() => void) | undefined
|
||||
@@ -1691,7 +1728,6 @@ export function PlayerPage() {
|
||||
|
||||
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
|
||||
const hlsStartSecRef = useRef(hlsStartSec)
|
||||
const modeRef = useRef(mode)
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
hlsSourceRef.current = hlsSource
|
||||
@@ -2455,7 +2491,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,
|
||||
@@ -2493,8 +2529,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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, Scissors, Timer, X } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Gauge, ListMusic, Loader2, Pause, Play, RefreshCw, Scissors, Timer, X } from 'lucide-react'
|
||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||
|
||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||
@@ -196,9 +196,21 @@ export function ReaderAudioPanel({ theme, onToggleMenu }: ReaderAudioPanelProps)
|
||||
</p>
|
||||
<p className="mt-1 text-2xs opacity-60" style={{ color: theme.text }}>
|
||||
{audioError ? (
|
||||
<span className="font-bold" style={{ color: theme.accent }}>
|
||||
{audioError}
|
||||
</span>
|
||||
// 失败态下这行就是重试入口:服务端这一轮要十几秒才判定失败,
|
||||
// 与其让用户对着转圈,不如把「为什么」和「再试一次」一起给他。
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
engine.retry()
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-2xs font-bold opacity-100"
|
||||
style={{ backgroundColor: theme.accent + '1a', color: theme.accent }}
|
||||
title="重新请求音源"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{audioError} · 立即重试
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} ·
|
||||
|
||||
@@ -23,6 +23,9 @@ export default function ReaderBookPage() {
|
||||
const [info, setInfo] = useState<ReaderBookInfo | null>(null)
|
||||
const [infoError, setInfoError] = useState('')
|
||||
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
|
||||
// 目录拉取失败的原因(书源限流/失联)。有它时不再把空列表显示成「0 章」,
|
||||
// 否则用户以为这本书真的没有目录。
|
||||
const [tocError, setTocError] = useState('')
|
||||
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
|
||||
@@ -46,19 +49,48 @@ export default function ReaderBookPage() {
|
||||
useEffect(loadInfo, [bookURL])
|
||||
|
||||
// 书架状态 + 目录
|
||||
//
|
||||
// 两个请求都依赖书源上游,而聚合源的免费后端经常限流(HTTP 429)。目录失败时
|
||||
// 不再静默显示「0 章」:先退回书架里的目录缓存(至少能看章节、点开阅读),
|
||||
// 没有缓存才把失败原因交给目录区展示,用户点「重试」即可。
|
||||
useEffect(() => {
|
||||
if (!bookURL) return
|
||||
readerAPI
|
||||
.listBooks()
|
||||
.then((books) => {
|
||||
const hit = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
|
||||
setShelfBook(hit)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
readerAPI
|
||||
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
|
||||
.then(setChapters)
|
||||
.catch(() => setChapters([]))
|
||||
let alive = true
|
||||
setChapters(null)
|
||||
setTocError('')
|
||||
;(async () => {
|
||||
let shelf: ReaderBook | null = null
|
||||
try {
|
||||
const books = await readerAPI.listBooks()
|
||||
shelf = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
|
||||
} catch {
|
||||
// 书架读取失败不影响详情页展示
|
||||
}
|
||||
if (!alive) return
|
||||
setShelfBook(shelf)
|
||||
try {
|
||||
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
|
||||
if (!alive) return
|
||||
setChapters(list)
|
||||
} catch (e) {
|
||||
if (!alive) return
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录加载失败'
|
||||
if (shelf) {
|
||||
const cached = await readerAPI.listChapters(shelf.id).catch(() => [])
|
||||
if (!alive) return
|
||||
if (cached.length > 0) {
|
||||
setChapters(cached.map((c) => ({ ...c, update_time: '' })))
|
||||
setTocError(`${msg}(已显示书架缓存)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
setChapters([])
|
||||
setTocError(msg)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [bookURL, sourceURL])
|
||||
|
||||
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
|
||||
@@ -77,7 +109,10 @@ export default function ReaderBookPage() {
|
||||
if (!tocURL || tocURL === bookURL) return
|
||||
readerAPI
|
||||
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL })
|
||||
.then(setChapters)
|
||||
.then((list) => {
|
||||
setChapters(list)
|
||||
setTocError('')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [info?.toc_url, sourceURL, bookURL])
|
||||
|
||||
@@ -142,9 +177,12 @@ export default function ReaderBookPage() {
|
||||
try {
|
||||
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL })
|
||||
setChapters(list)
|
||||
setTocError('')
|
||||
toast.success(`目录已刷新,共 ${list.length} 章`)
|
||||
} catch {
|
||||
toast.error('目录刷新失败')
|
||||
} catch (e) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录刷新失败'
|
||||
setTocError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -256,7 +294,7 @@ export default function ReaderBookPage() {
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
|
||||
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || '加载中…'}</span>
|
||||
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || (info ? '—' : '加载中…')}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">书源:{originName || sourceURL}</p>
|
||||
{(info?.intro || infoError) && (
|
||||
@@ -264,7 +302,15 @@ export default function ReaderBookPage() {
|
||||
<p className={`text-xs leading-5 text-[var(--app-subtle)] ${introExpanded ? '' : 'line-clamp-3'}`}>
|
||||
{infoError || info?.intro}
|
||||
</p>
|
||||
{!infoError && (
|
||||
{infoError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadInfo}
|
||||
className="mt-1 inline-flex items-center text-2xs font-bold text-brand-600"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIntroExpanded((v) => !v)}
|
||||
@@ -308,7 +354,10 @@ export default function ReaderBookPage() {
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-2">
|
||||
<p className="text-sm font-bold text-[var(--app-text)]">
|
||||
目录 {chapters !== null && <span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>}
|
||||
目录{' '}
|
||||
{chapters !== null && (chapters.length > 0 || !tocError) && (
|
||||
<span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>
|
||||
)}
|
||||
</p>
|
||||
{chapters !== null && chapters.length > 20 && (
|
||||
<button
|
||||
@@ -320,6 +369,19 @@ export default function ReaderBookPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tocError && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||||
<span className="min-w-0 flex-1">{tocError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshToc}
|
||||
disabled={busy}
|
||||
className="shrink-0 font-bold text-brand-600 disabled:opacity-50"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{chapters === null ? (
|
||||
<div className="flex justify-center py-10 text-[var(--app-muted)]">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
|
||||
@@ -22,6 +22,15 @@ import { readerCoverSrc } from '../../utils/readerCover'
|
||||
const PROGRESS_SAVE_INTERVAL_MS = 5_000
|
||||
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
|
||||
const SEEK_STEP = 15
|
||||
/**
|
||||
* 音源加载失败后自动重试的退避间隔(毫秒),数组长度即自动重试次数。
|
||||
*
|
||||
* 别调得太短:服务端媒体代理要回源换票、拉整章音频,失败一次本身就要十几秒,
|
||||
* 立刻重试只会让用户对着转圈更久。第一次 3 秒、第二次 9 秒,总等待可控。
|
||||
*/
|
||||
const AUDIO_RETRY_DELAYS_MS = [3_000, 9_000]
|
||||
/** 自动重试次数上限(与退避表的长度一致)。 */
|
||||
const AUDIO_RETRY_LIMIT = AUDIO_RETRY_DELAYS_MS.length
|
||||
|
||||
export interface LoadChapterInput {
|
||||
bookId: string
|
||||
@@ -78,6 +87,10 @@ class ReaderAudioEngine {
|
||||
private warmer: HTMLAudioElement | null = null
|
||||
/** warmer 当前装载的音轨,避免重复赋值。 */
|
||||
private warmedTrack = ''
|
||||
/** 当前章已自动重试的次数(换章/加载成功时清零)。 */
|
||||
private retryCount = 0
|
||||
/** 自动重试的定时器句柄。 */
|
||||
private retryTimer: number | null = null
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
@@ -107,6 +120,8 @@ class ReaderAudioEngine {
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
// 换章是一次全新的加载:清掉上一章遗留的自动重试计数/定时器
|
||||
this.resetRetry()
|
||||
// 换书:预取的音轨属于上一本,全部作废
|
||||
if (s.bookId !== input.bookId) this.clearPrefetch()
|
||||
this.applyChapter(input)
|
||||
@@ -175,10 +190,23 @@ class ReaderAudioEngine {
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
// 失败态下播放键就是「重试」:重新解析本章音轨(换一张签名票)再装源,
|
||||
// 而不是对着一份已经出错的 src 再调 play()。
|
||||
if (useReaderAudioStore.getState().status === 'error') {
|
||||
this.retry()
|
||||
return
|
||||
}
|
||||
if (this.audio.paused) this.play()
|
||||
else this.pause()
|
||||
}
|
||||
|
||||
/** 用户手动重试:清掉自动重试计数与待执行定时器,立刻重新解析本章音轨。 */
|
||||
retry(): void {
|
||||
this.resetRetry()
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
void this.reloadCurrentChapter()
|
||||
}
|
||||
|
||||
seekBy(delta: number): void {
|
||||
const total = Number.isFinite(this.audio.duration)
|
||||
? this.audio.duration
|
||||
@@ -244,6 +272,7 @@ class ReaderAudioEngine {
|
||||
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.resetRetry()
|
||||
this.detachSource()
|
||||
this.clearPrefetch()
|
||||
this.timerDeadline = 0
|
||||
@@ -457,10 +486,80 @@ class ReaderAudioEngine {
|
||||
}
|
||||
|
||||
private fail(message: string): void {
|
||||
this.resetRetry()
|
||||
useReaderAudioStore.setState({ status: 'error', error: message })
|
||||
this.setSessionPlaybackState('paused')
|
||||
}
|
||||
|
||||
// ─── 失败自动重试 ─────────────────────────────────────────────────────
|
||||
|
||||
/** 清掉自动重试计数与待执行的定时器(换章、停止、最终失败时调用)。 */
|
||||
private resetRetry(): void {
|
||||
this.retryCount = 0
|
||||
if (this.retryTimer !== null) {
|
||||
window.clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退避后再重新解析本章音轨。
|
||||
*
|
||||
* 期间刻意停留在 error 态而不是 loading:服务端这次请求本来就要十几秒才判定
|
||||
* 失败,再挂一个转圈只会让人以为卡死。明确告诉他「几秒后自动重试」,并且这
|
||||
* 行提示本身可以点,等于把等待权交回用户。
|
||||
*/
|
||||
private scheduleRetry(): void {
|
||||
if (this.retryTimer !== null) window.clearTimeout(this.retryTimer)
|
||||
const delay = AUDIO_RETRY_DELAYS_MS[Math.min(this.retryCount - 1, AUDIO_RETRY_LIMIT - 1)]
|
||||
useReaderAudioStore.setState({
|
||||
status: 'error',
|
||||
error: `音源暂时取不到,${Math.round(delay / 1000)} 秒后自动重试`,
|
||||
})
|
||||
this.retryTimer = window.setTimeout(() => {
|
||||
this.retryTimer = null
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
void this.reloadCurrentChapter()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新取一次章节内容再装源。服务端媒体代理会在签名票失效/上游抖动时
|
||||
* 重新回源签票;重新取内容能拿到新的签名地址,比原样重试同一个 URL 有效。
|
||||
*/
|
||||
private async reloadCurrentChapter(): Promise<void> {
|
||||
const s = useReaderAudioStore.getState()
|
||||
if (!s.bookId) {
|
||||
this.fail('音频加载失败,请稍后重试')
|
||||
return
|
||||
}
|
||||
const token = this.loadToken
|
||||
const resumeAt = this.lastTime > 0 ? this.lastTime : s.cur
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(s.bookId, s.chapterIndex)
|
||||
if (token !== this.loadToken) return
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) {
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
this.applyChapter({
|
||||
bookId: s.bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters: s.chapters,
|
||||
chapterIndex: s.chapterIndex,
|
||||
track,
|
||||
transcoding: ct.transcoding ?? false,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos: resumeAt,
|
||||
})
|
||||
} catch {
|
||||
this.fail('音频加载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── <audio> 事件 ─────────────────────────────────────────────────────
|
||||
|
||||
private bindAudio(): void {
|
||||
@@ -546,11 +645,22 @@ class ReaderAudioEngine {
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
const code = audio.error?.code
|
||||
// 服务端代理拉的是第三方 CDN 的直链,跨境链路会偶发被上游拒绝 / 中途断流。
|
||||
// 浏览器对这两种情况都报 SRC_NOT_SUPPORTED(看起来像「格式不支持」),
|
||||
// 实际上重新取一次章节(换一张签名票)通常就能播,所以先自动重试。
|
||||
const retryable =
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED || code === MediaError.MEDIA_ERR_NETWORK
|
||||
if (retryable && this.retryCount < AUDIO_RETRY_LIMIT) {
|
||||
this.retryCount++
|
||||
this.scheduleRetry()
|
||||
return
|
||||
}
|
||||
// 重试用完:文案要能解释「为什么播不了」,并且面板上这行本身就是重试按钮。
|
||||
this.fail(
|
||||
code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED
|
||||
? '浏览器无法播放该音频格式,且服务端转码不可用(请检查 ffmpeg 是否已安装)'
|
||||
? '音源暂时被上游拒绝'
|
||||
: code === MediaError.MEDIA_ERR_NETWORK
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
? '网络异常,音源没取到'
|
||||
: '音频无法播放',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -129,17 +129,17 @@ export const generalSettingsGroup: SettingGroup = {
|
||||
},
|
||||
{
|
||||
key: 'cache.images_max_size_mb',
|
||||
label: '图片缓存上限 (MB)',
|
||||
label: '磁盘缓存总容量上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '海报、剧照等图片代理缓存目录 (cache/images) 的总占用上限。超过上限时先淘汰原图、再按修改时间清理最旧的缩略图;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB',
|
||||
defaultValue: '2000',
|
||||
placeholder: '2000',
|
||||
hint: '磁盘缓存(hls、images、library-covers)的总占用上限。超过上限时严格按「转码 HLS → 图片 → 媒体库封面」的顺序淘汰最旧的文件,直到回落到上限的 80%。有声书转码缓存(reader-audio)另有 1GB 写入时淘汰,不计入这里。设为 0 表示不限制,默认 2048MB(2GB)',
|
||||
defaultValue: '2048',
|
||||
placeholder: '2048',
|
||||
},
|
||||
{
|
||||
key: 'cache.images_originals_max_size_mb',
|
||||
label: '原图缓存上限 (MB)',
|
||||
label: '图片原图缓存上限 (MB)',
|
||||
type: 'number',
|
||||
hint: '原图只是生成缩略图的原料,丢了可以重新回源,因此单独给一个小配额(默认 256MB)。总量超限时原图也优先被淘汰,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
|
||||
hint: 'originals 子目录的独立配额(默认 256MB)。原图只是生成缩略图的原料,丢了可以重新回源,因此单独给小配额;图片池被淘汰时优先删原图,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制',
|
||||
defaultValue: '256',
|
||||
placeholder: '256',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user