This commit is contained in:
truewhile
2026-10-29 22:52:39 +08:00
parent 0ab4693b92
commit d820214abc
5 changed files with 695 additions and 6 deletions
+83 -3
View File
@@ -189,11 +189,15 @@ func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) {
}
}
// TestReaderMediaAbortsWhenUpstreamTruncates 上游中途断开且重试都失败时,
// 代理必须让客户端看到「传输不完整」,不能返回一个长度正确却内容缺失的响应。
// 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) {
@@ -217,7 +221,8 @@ func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL)
router := mediaRouterForTest(svc)
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media")
// .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。
signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
@@ -239,6 +244,71 @@ func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) {
}
}
// 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)
}
}
// TestReaderAudioCacheRejectsTruncatedUpstream 音轨拉不完整时重试;全部失败回 502,
// 且不能留下半截缓存文件(否则后续会把截断的音频当完整文件下发)。
func TestReaderAudioCacheRejectsTruncatedUpstream(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")
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()
@@ -280,6 +350,7 @@ type mediaTicketServer struct {
mu sync.Mutex
issued []string
hits int
mediaHits int
noToken bool
errorPage bool
}
@@ -306,6 +377,9 @@ func newMediaTicketServer(t *testing.T) *mediaTicketServer {
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++
s.mu.Unlock()
if s.errorPageNow() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>ticket expired</body></html>`))
@@ -373,6 +447,12 @@ func (s *mediaTicketServer) contentHits() int {
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()
+268 -1
View File
@@ -8,12 +8,16 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"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"
@@ -878,6 +882,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
// 远端音轨:整段落盘缓存,命中后不再回源。
//
// 听书源的音频直链签在腾讯音乐 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
@@ -969,6 +989,209 @@ func streamReaderMediaBody(w io.Writer, body io.Reader) {
}
}
// ─── 远端音轨落盘缓存 ──────────────────────────────────────────────────────
const (
// readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。
readerAudioCacheAttempts = 4
// readerAudioCacheRetryDelay 重试退避基数,实际等待为 (attempt-1) 倍。
readerAudioCacheRetryDelay = 500 * time.Millisecond
// readerAudioCacheMaxBytes 单个音频的落盘上限。有声书一章约 3–5MB,
// 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。
readerAudioCacheMaxBytes = 64 << 20
// readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的,
// 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。
readerMediaReissueAttempts = 3
readerMediaReissueDelay = 300 * time.Millisecond
)
// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。
var readerAudioFetchGroup singleflight.Group
// handleCachedReaderAudio 音频直链的统一入口:先查本地缓存,未命中则整段拉取落盘,
// 最后交给 http.ServeContent 处理 Range(拖动进度、续播都读本地文件)。
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
}
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)
}
// 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 整段拉取并落盘:上游断流/换票失败时重试,
// 落盘后按 Content-Length 校验长度,防止把截断的音频缓存下来。
func downloadReaderAudioToCache(
ctx context.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) error {
var lastErr error
for attempt := 1; attempt <= readerAudioCacheAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(readerAudioCacheRetryDelay * time.Duration(attempt-1)):
}
}
out, err := fetchReaderMediaResilient(ctx, svc, book, rawURL, "", headers, true)
if err != nil {
lastErr = err
continue
}
if out == nil || out.resp == nil {
lastErr = fmt.Errorf("音源响应为空")
continue
}
resp := out.resp
status := resp.StatusCode
if status != http.StatusOK && status != http.StatusPartialContent {
_ = resp.Body.Close()
lastErr = fmt.Errorf("音源返回 %d", status)
continue
}
expected := resp.ContentLength
path, storeErr := svc.Reader.StoreCachedRemoteAudio(bookID, rawURL, func(w io.Writer) error {
if len(out.head) > 0 {
if _, err := w.Write(out.head); err != nil {
return err
}
}
return copyLimited(w, resp.Body, readerAudioCacheMaxBytes)
})
_ = resp.Body.Close()
if storeErr != nil {
lastErr = storeErr
continue
}
if expected > 0 {
info, statErr := os.Stat(path)
if statErr != nil {
_ = os.Remove(path)
lastErr = fmt.Errorf("音频缓存写入后不可读: %w", statErr)
continue
}
if info.Size() != expected {
_ = os.Remove(path)
lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", info.Size(), expected)
continue
}
}
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("音源拉取失败")
}
return fmt.Errorf("已重试 %d 次: %w", readerAudioCacheAttempts, lastErr)
}
// copyLimited 复制响应体,超过上限时报错(避免把超大对象写进缓存)。
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
n, err := io.Copy(w, io.LimitReader(r, limit+1))
if err != nil {
return err
}
if n > limit {
return fmt.Errorf("音源超过 %d 字节上限", limit)
}
return nil
}
// 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
@@ -1034,7 +1257,7 @@ func fetchReaderMedia(
case out.resp.StatusCode == http.StatusUnauthorized:
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
return nil, fmt.Errorf("音源鉴权被拒(上游 401),请稍后重试或换源")
return nil, fmt.Errorf("音源签名失效(上游 401 拒绝),请稍后重试或换源")
}
return out, nil
}
@@ -1058,6 +1281,50 @@ func fetchReaderMedia(
return retried, nil
}
// fetchReaderMediaResilient 在 fetchReaderMedia(已含一次换票)之上再包一层。
//
// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次,
// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮,
// 每轮都会作废章节缓存、重新回源签票。不可恢复的 4xx(如 404)原样返回,
// 不做无意义的等待。
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
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
}
lastErr = fmt.Errorf("音源换票后仍不可用(上游 %d)", out.resp.StatusCode)
_ = out.resp.Body.Close()
continue
}
lastErr = err
if ctx.Err() != nil {
return nil, err
}
}
return nil, lastErr
}
// readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。
//
// 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章
@@ -0,0 +1,222 @@
package reader
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// 远端音轨的本地落盘缓存(媒体代理 /api/reader/media 使用)。
//
// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401
// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新
// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象路径」把整段音频
// 落盘(忽略 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent
// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。
const (
audioMediaDirName = "reader-audio"
audioMediaTTL = 7 * 24 * time.Hour
audioMediaMaxBytes = 2 << 30 // 2GiB:超出后按 mtime 淘汰最旧的
audioMediaPrunePeriod = 10 * time.Minute
audioMediaPruneKeepPct = 90
)
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)
}
// audioMediaCacheKey 缓存键:书 + scheme://host/path。
//
// 必须忽略 query:同一段音频每次换票都会得到新的 vkey/uid/ct,只按完整 URL
// 取哈希的话缓存永远打不中。
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()
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
}
// 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
}
// StoreCachedRemoteAudio 把一段音源原子写入缓存,fill 负责把内容写进 writer。
// 返回最终文件路径;内容为空或写入失败时不留下半成品。
func (s *ReaderService) StoreCachedRemoteAudio(bookID, mediaURL string, fill func(io.Writer) error) (string, error) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return "", errors.New("音频缓存目录未配置")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return "", fmt.Errorf("创建音频缓存目录失败: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".audio-*")
if err != nil {
return "", fmt.Errorf("创建音频缓存文件失败: %w", err)
}
tmpName := tmp.Name()
fillErr := fill(tmp)
closeErr := tmp.Close()
if fillErr != nil || closeErr != nil {
_ = os.Remove(tmpName)
if fillErr != nil {
return "", fillErr
}
return "", closeErr
}
info, statErr := os.Stat(tmpName)
if statErr != nil || info.Size() == 0 {
_ = os.Remove(tmpName)
return "", errors.New("音源内容为空")
}
if err := os.Rename(tmpName, path); err != nil {
_ = os.Remove(tmpName)
return "", fmt.Errorf("写入音频缓存失败: %w", err)
}
s.pruneAudioMediaCache()
return path, nil
}
// 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,41 @@
package reader
import "testing"
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,缓存键必须只看
// scheme + host + path,否则每次换票都会重新下载一遍。
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("不同对象的缓存键不应相同")
}
}
+81 -2
View File
@@ -22,6 +22,8 @@ import { readerCoverSrc } from '../../utils/readerCover'
const PROGRESS_SAVE_INTERVAL_MS = 5_000
/** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */
const SEEK_STEP = 15
/** 音源加载失败后自动重试(重新换取签名地址)的次数上限。 */
const AUDIO_RETRY_LIMIT = 2
export interface LoadChapterInput {
bookId: string
@@ -78,6 +80,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 +113,8 @@ class ReaderAudioEngine {
}
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
this.commitProgress()
// 换章是一次全新的加载:清掉上一章遗留的自动重试计数/定时器
this.resetRetry()
// 换书:预取的音轨属于上一本,全部作废
if (s.bookId !== input.bookId) this.clearPrefetch()
this.applyChapter(input)
@@ -244,6 +252,7 @@ class ReaderAudioEngine {
/** 完全停止并清空状态(迷你播放器的关闭按钮)。 */
stop(): void {
this.commitProgress()
this.resetRetry()
this.detachSource()
this.clearPrefetch()
this.timerDeadline = 0
@@ -457,10 +466,70 @@ 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
}
}
/** 退避后再重新解析本章音轨;期间保持 loading 态,不给用户闪错误。 */
private scheduleRetry(): void {
if (this.retryTimer !== null) window.clearTimeout(this.retryTimer)
useReaderAudioStore.setState({ status: 'loading', error: '' })
const delay = 800 * this.retryCount
this.retryTimer = window.setTimeout(() => {
this.retryTimer = null
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 +615,21 @@ class ReaderAudioEngine {
audio.addEventListener('error', () => {
const code = audio.error?.code
// 服务端代理拉的是腾讯 CDN 的直链,跨境链路会偶发 502 / 中途断流。
// 浏览器对这两种情况都报 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
? '音频加载失败,请检查网络或源文件是否还在'
? '音频加载失败,请检查网络后重试'
: '音频无法播放',
)
})