Files
MeBox/internal/service/reader/media_refresh.go
T
truewhile db516ded61 优化
2026-10-29 00:09:00 +08:00

191 lines
6.8 KiB
Go

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
}