mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 12:34:22 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d820214abc | |||
| 0ab4693b92 |
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -408,6 +412,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)
|
||||
@@ -874,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
|
||||
@@ -965,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
|
||||
|
||||
@@ -1030,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
|
||||
}
|
||||
@@ -1054,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("不同对象的缓存键不应相同")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ func parseConcurrentRate(rate string) time.Duration {
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。
|
||||
// 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右;
|
||||
// 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。
|
||||
implicitRetryAttempts = 3
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
@@ -98,6 +102,20 @@ 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 一样属于
|
||||
|
||||
@@ -425,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
|
||||
@@ -464,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
|
||||
}
|
||||
@@ -536,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 是一次书源操作的执行上下文:把书源记录、解析结构与
|
||||
@@ -1131,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))
|
||||
@@ -1164,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,
|
||||
@@ -1221,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://"):
|
||||
@@ -1282,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)
|
||||
|
||||
@@ -1319,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
|
||||
@@ -1569,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)。
|
||||
@@ -1695,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)
|
||||
|
||||
@@ -1821,7 +1902,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
|
||||
}
|
||||
@@ -2082,10 +2163,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)
|
||||
@@ -2550,7 +2634,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 {
|
||||
@@ -2611,7 +2695,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
? '音频加载失败,请检查网络或源文件是否还在'
|
||||
? '音频加载失败,请检查网络后重试'
|
||||
: '音频无法播放',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user