Compare commits

..

7 Commits

Author SHA1 Message Date
truewhile 9ffac320c4 优化 2026-11-04 10:16:12 +08:00
truewhile 9b8ca33279 bug处理 2026-11-01 09:23:58 +08:00
truewhile 34e61a5994 优化 2026-10-31 12:48:50 +08:00
truewhile d91ded9d55 优化 2026-10-30 18:54:26 +08:00
truewhile 8458b8060a 优化 2026-10-30 10:45:59 +08:00
truewhile 623d87f12b 优化 2026-10-29 23:47:18 +08:00
truewhile d820214abc 优化 2026-10-29 22:52:39 +08:00
48 changed files with 4719 additions and 236 deletions
+1 -1
View File
@@ -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", "")
+7 -2
View File
@@ -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"`
+1 -1
View File
@@ -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 {
+406
View File
@@ -0,0 +1,406 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"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"
)
// 音轨「区间直通」的行为契约。
//
// 背景(真实故障):播放器(media3)取一条 m4a 会开两次请求——先整段打开嗅探
// 容器,再带 Range 从目标位置重开。旧实现只允许「无 Range + 无分片」的请求
// 边下边发,于是带 Range 的那次(真正的起播请求)必须把整章下完再按区间下发。
// 线上日志:每章固定 200(探针,被播放器掐断)+ 206(整章下完才吐第一个字节),
// 42 章里「媒体已装载 → 实际起播」平均 4.79 秒,最坏 35 秒。
//
// 现在的契约:
// - 带 Range 的冷缓存请求 → 上游一到字节就往下发,不等整章;
// - 分片已有的前缀从本地发,上游只续下剩余字节;
// - 整章落满时把分片提升为正式缓存,之后走本地;
// - 上游不认 Range 时也要给出正确的 206(跳过前缀),且不写带洞的分片;
// - 拿不到写锁时降级成纯透传,绝不排队等前一个下载。
// rangeUpstream 支持 Range 的上游:/content 给出音轨地址,/media 回音频本体。
type rangeUpstream struct {
*httptest.Server
payload []byte
honorRange bool
// firstChunk > 0 且 release 非 nil 时:先写这么多字节再等放行,
// 用来证明「客户端不必等整章下完」。
firstChunk int
release chan struct{}
mu sync.Mutex
ranges []string
hits int
}
func newRangeUpstream(t *testing.T, payload []byte, honorRange bool) *rangeUpstream {
t.Helper()
u := &rangeUpstream{payload: payload, honorRange: honorRange}
u.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":
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media"}}`, u.URL)))
case r.URL.Path == "/media":
u.serveMedia(w, r)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(u.Close)
return u
}
func (u *rangeUpstream) serveMedia(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.hits++
u.ranges = append(u.ranges, r.Header.Get("Range"))
u.mu.Unlock()
total := len(u.payload)
start, end := 0, total-1
partial := false
if u.honorRange {
if s, e, ok := parseReaderAudioRange(r.Header.Get("Range")); ok {
start = int(s)
if e >= 0 && int(e) < end {
end = int(e)
}
partial = true
}
}
w.Header().Set("Content-Type", "audio/mp4")
w.Header().Set("Accept-Ranges", "bytes")
if start >= total {
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return
}
body := u.payload[start : end+1]
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if partial {
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
w.WriteHeader(http.StatusPartialContent)
} else {
w.WriteHeader(http.StatusOK)
}
if u.release != nil && u.firstChunk > 0 && len(body) > u.firstChunk {
_, _ = w.Write(body[:u.firstChunk])
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
select {
case <-u.release:
case <-r.Context().Done():
return
}
body = body[u.firstChunk:]
}
_, _ = w.Write(body)
}
func (u *rangeUpstream) rangeHeaders() []string {
u.mu.Lock()
defer u.mu.Unlock()
return append([]string(nil), u.ranges...)
}
func (u *rangeUpstream) requestCount() int {
u.mu.Lock()
defer u.mu.Unlock()
return u.hits
}
// prepareRangeBook 建一本「书源直连上游」的音频书,并取回该音轨的签名代理路径。
func prepareRangeBook(t *testing.T, upstream string) (*service.Container, *model.ReaderBook, string) {
t.Helper()
svc := newReaderHandlerContainer(t)
svc.Cfg.Cache.CacheDir = t.TempDir()
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)
}
if err := svc.Reader.SaveChapters(t.Context(), book.ID, []reader.ChapterInput{
{Index: 0, Title: "第一章", URL: upstream + "/content"},
}); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", book.ID, 0, reader.ContentOptions{})
if err != nil {
t.Fatalf("取章节失败: %v", err)
}
if len(out.Tracks) != 1 {
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
}
return svc, book, out.Tracks[0]
}
func audioPayload(n int) []byte {
payload := make([]byte, n)
for i := range payload {
payload[i] = byte(i % 251)
}
return payload
}
// waitCached 等待分片被提升为正式缓存(提升发生在响应收尾,与客户端读完成之间有微小竞态)。
func waitCached(t *testing.T, svc *service.Container, bookID, mediaURL string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if svc.Reader.HasCachedRemoteAudio(bookID, mediaURL) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("整章下完后应提升为正式缓存")
}
// TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter 冷缓存 + Range:
// 上游只吐了第一段,客户端就必须已经拿到字节。
func TestReaderAudioRangeStreamsWithoutWaitingForWholeChapter(t *testing.T) {
payload := audioPayload(512 << 10)
upstream := newRangeUpstream(t, payload, true)
upstream.firstChunk = 64 << 10
upstream.release = make(chan struct{})
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
srv := httptest.NewServer(mediaRouterForTest(svc))
defer srv.Close()
req, err := http.NewRequest(http.MethodGet, srv.URL+track, nil)
if err != nil {
t.Fatalf("构造请求失败: %v", err)
}
req.Header.Set("Range", "bytes=0-")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206", resp.StatusCode)
}
if got := resp.Header.Get("Content-Range"); got != fmt.Sprintf("bytes 0-%d/%d", len(payload)-1, len(payload)) {
t.Fatalf("Content-Range = %q", got)
}
if got := resp.Header.Get("Content-Length"); got != strconv.Itoa(len(payload)) {
t.Fatalf("Content-Length = %q,期望 %d", got, len(payload))
}
// 上游此时还被 release 卡着:能在超时内读到第一段就说明没有等整章。
head := make([]byte, upstream.firstChunk)
readDone := make(chan error, 1)
go func() {
_, readErr := io.ReadFull(resp.Body, head)
readDone <- readErr
}()
select {
case readErr := <-readDone:
if readErr != nil {
t.Fatalf("读首段失败: %v", readErr)
}
case <-time.After(3 * time.Second):
t.Fatalf("上游还没给完就卡住了:客户端没能在整章下完前拿到第一个字节")
}
if !bytes.Equal(head, payload[:upstream.firstChunk]) {
t.Fatalf("首段字节不一致")
}
close(upstream.release)
rest, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("读剩余字节失败: %v", err)
}
if got := append(head, rest...); !bytes.Equal(got, payload) {
t.Fatalf("整体字节不一致: got %d bytes", len(got))
}
if got := upstream.rangeHeaders(); len(got) != 1 || got[0] != "bytes=0-" {
t.Fatalf("上游收到的 Range = %v,期望且仅一次 bytes=0-", got)
}
waitCached(t, svc, book.ID, mediaURL)
}
// TestReaderAudioRangeResumesFromPartialFragment 分片里已有前缀时,前缀从本地发,
// 上游只续下剩余字节;整章落满后提升为正式缓存。
func TestReaderAudioRangeResumesFromPartialFragment(t *testing.T) {
payload := audioPayload(256 << 10)
upstream := newRangeUpstream(t, payload, true)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
// 模拟「播放器嗅探完就断开」留下的分片:前 96KB 已经在盘上。
const partLen = 96 << 10
cachePath := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
if cachePath == "" {
t.Fatalf("缓存路径为空")
}
if mkErr := os.MkdirAll(cachePath[:strings.LastIndex(cachePath, string(os.PathSeparator))], 0o750); mkErr != nil {
t.Fatalf("建缓存目录失败: %v", mkErr)
}
if writeErr := os.WriteFile(cachePath+".part", payload[:partLen], 0o640); writeErr != nil {
t.Fatalf("写分片失败: %v", writeErr)
}
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload) {
t.Fatalf("拼接后的字节不一致: got %d bytes want %d", len(body), len(payload))
}
headers := upstream.rangeHeaders()
if len(headers) != 1 || headers[0] != fmt.Sprintf("bytes=%d-", partLen) {
t.Fatalf("上游收到的 Range = %v,期望从分片长度续下 bytes=%d-", headers, partLen)
}
waitCached(t, svc, book.ID, mediaURL)
// 提升之后同一首歌不再回源。
before := upstream.requestCount()
code, body = getReaderMedia(t, mediaRouterForTest(svc), track)
if code != http.StatusOK || !bytes.Equal([]byte(body), payload) {
t.Fatalf("命中缓存失败 status=%d len=%d", code, len(body))
}
if after := upstream.requestCount(); after != before {
t.Fatalf("命中缓存后不应再回源,多了 %d 次", after-before)
}
}
// TestReaderAudioRangeHandlesUpstreamThatIgnoresRange 上游不认 Range(回 200 整段)时,
// 仍要按客户端要的起点给出正确的 206,并且不写带洞的分片。
func TestReaderAudioRangeHandlesUpstreamThatIgnoresRange(t *testing.T) {
payload := audioPayload(300 << 10)
upstream := newRangeUpstream(t, payload, false)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
const start = 1000
code, body := getReaderMediaWithRange(t, mediaRouterForTest(svc), track, fmt.Sprintf("bytes=%d-", start))
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload[start:]) {
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload)-start)
}
// 起点不在分片内 → 不能落盘(否则分片开头会是空洞)。
if svc.Reader.HasPartialRemoteAudio(book.ID, mediaURL) {
t.Fatalf("起点靠后的区间请求不应生成分片")
}
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
t.Fatalf("只下了一段不应生成完整缓存")
}
}
// TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy 拿不到写锁时不排队:
// 仍然立刻把区间发出去,只是这一轮不落盘。
func TestReaderAudioRangeFallsBackToPassthroughWhenWriterBusy(t *testing.T) {
payload := audioPayload(128 << 10)
upstream := newRangeUpstream(t, payload, true)
svc, book, track := prepareRangeBook(t, upstream.URL)
mediaURL := upstream.URL + "/media"
cacheKey := svc.Reader.RemoteAudioCachePath(book.ID, mediaURL)
if cacheKey == "" {
t.Fatalf("缓存路径为空")
}
unlock := readerAudioStreamLocks.Lock(cacheKey)
defer unlock()
done := make(chan struct{})
var code int
var body string
go func() {
code, body = getReaderMediaWithRange(t, mediaRouterForTest(svc), track, "bytes=0-")
close(done)
}()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatalf("写锁被占用时不该让请求排队等待")
}
if code != http.StatusPartialContent {
t.Fatalf("状态码 = %d,期望 206 body=%.120s", code, body)
}
if !bytes.Equal([]byte(body), payload) {
t.Fatalf("区间字节不一致: got %d want %d", len(body), len(payload))
}
if svc.Reader.HasCachedRemoteAudio(book.ID, mediaURL) {
t.Fatalf("降级透传不应写缓存")
}
}
func TestParseReaderAudioRangeCases(t *testing.T) {
cases := []struct {
in string
start int64
end int64
want bool
}{
{"bytes=0-", 0, -1, true},
{"bytes=100-199", 100, 199, true},
{"Bytes=1024-", 1024, -1, true},
{" bytes=5-5 ", 5, 5, true},
{"bytes=-500", 0, 0, false},
{"bytes=0-99,200-299", 0, 0, false},
{"items=0-", 0, 0, false},
{"bytes=abc-", 0, 0, false},
{"bytes=200-100", 0, 0, false},
{"", 0, 0, false},
}
for _, c := range cases {
start, end, ok := parseReaderAudioRange(c.in)
if ok != c.want || start != c.start || end != c.end {
t.Errorf("parseReaderAudioRange(%q) = (%d,%d,%v),期望 (%d,%d,%v)",
c.in, start, end, ok, c.start, c.end, c.want)
}
}
}
// getReaderMediaWithRange 发一次带 Range 头的媒体代理请求。
func getReaderMediaWithRange(t *testing.T, router *gin.Engine, signedPath, rangeHeader string) (int, string) {
t.Helper()
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signedPath, nil)
req.Header.Set("Range", rangeHeader)
router.ServeHTTP(w, req)
return w.Code, w.Body.String()
}
+328 -9
View File
@@ -1,7 +1,9 @@
package handler
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
@@ -9,6 +11,7 @@ import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
@@ -97,6 +100,99 @@ func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) {
}
}
// 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) {
@@ -189,11 +285,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 +317,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 +340,106 @@ 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)
}
}
// 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()
@@ -258,7 +459,7 @@ func getReaderMedia(t *testing.T, router *gin.Engine, signedPath string) (int, s
// primeTicketBook 先读一次章节(签出第一张票并落缓存),返回该音轨的签名代理路径。
func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string {
t.Helper()
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0)
out, err := svc.Reader.GetContentForBook(t.Context(), "u1", bookID, 0, reader.ContentOptions{})
if err != nil {
t.Fatalf("首次取章节失败: %v", err)
}
@@ -277,11 +478,13 @@ func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string
// - /img/* 图片资源,固定 403(模拟防盗链)
type mediaTicketServer struct {
*httptest.Server
mu sync.Mutex
issued []string
hits int
noToken bool
errorPage bool
mu sync.Mutex
issued []string
hits int
mediaHits int
noToken bool
errorPage bool
alwaysDeny bool
}
func newMediaTicketServer(t *testing.T) *mediaTicketServer {
@@ -306,6 +509,15 @@ 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++
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>`))
@@ -361,6 +573,13 @@ func (s *mediaTicketServer) serveErrorPage() {
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()
@@ -373,6 +592,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()
@@ -443,3 +668,97 @@ func prepareMediaTicketBook(t *testing.T) (*service.Container, *mediaTicketServe
}
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)
}
}
+805 -16
View File
@@ -8,12 +8,17 @@ import (
"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"
@@ -127,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})
}
@@ -878,6 +894,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 +1001,640 @@ func streamReaderMediaBody(w io.Writer, body io.Reader) {
}
}
// ─── 远端音轨落盘缓存 ──────────────────────────────────────────────────────
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()
}
}
// keyedMutexPollInterval 等锁的重试间隔。只用于「已有写入者时短暂等待」,
// 不值得为它引入条件变量。
const keyedMutexPollInterval = 20 * time.Millisecond
// LockWait 最多等 timeout 就放弃。
//
// 音频缓存的写入者可能是「首播直通」,它会一直持锁到客户端读完整章;让后来的
// 请求排队等它,播放器的重试就会干等几十秒(用户看到的就是「一直转圈」)。
// 等不到锁的请求改走纯透传,字节照样能出声,只是这次不落盘。
func (k *keyedMutex) LockWait(key string, timeout time.Duration) (func(), bool) {
deadline := time.Now().Add(timeout)
for {
if unlock, ok := k.acquire(key); ok {
return unlock, true
}
if time.Now().After(deadline) {
return nil, false
}
time.Sleep(keyedMutexPollInterval)
}
}
// acquire 取一次锁,取不到立刻返回。引用计数保证条目在最后一个持有者归还后回收。
func (k *keyedMutex) acquire(key string) (func(), bool) {
k.mu.Lock()
entry := k.locks[key]
if entry == nil {
entry = &keyedMutexEntry{}
k.locks[key] = entry
}
entry.refs++
k.mu.Unlock()
if !entry.mu.TryLock() {
k.release(key, entry)
return nil, false
}
return func() {
entry.mu.Unlock()
k.release(key, entry)
}, true
}
// release 归还一次引用,无人持有时回收条目。
func (k *keyedMutex) release(key string, entry *keyedMutexEntry) {
k.mu.Lock()
entry.refs--
if entry.refs == 0 {
delete(k.locks, key)
}
k.mu.Unlock()
}
// readerAudioLockWait 单条音轨的写锁最多等多久;等不到就降级成纯透传。
const readerAudioLockWait = 500 * time.Millisecond
// handleCachedReaderAudio 音频直链的统一入口。
//
// 四条路径,按代价从低到高:
// 1. 命中完整缓存 → http.ServeContent 按 Range 下发(拖动、续播都读本地);
// 2. 带 Range 的冷缓存 → 区间直通(serveReaderAudioRange):客户端要哪一段就
// 从上游边下边发哪一段,同时补全分片。**播放器嗅探完容器一定会带 Range 重开
// 一次**(media3 的固有行为),所以这条才是真正的起播路径。旧实现要求整章
// 下完才能按区间下发,每章起播都要等 1–4MB 跨境回源,实测 1.5–35 秒;
// 3. 冷启动整段请求 → 边下边发给客户端,同时落盘(streamReaderAudioInto):
// 播放器嗅探容器的那次 200 请求,几百毫秒就能出声,落盘照旧;
// 4. 已有 .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
}
cacheKey := svc.Reader.RemoteAudioCachePath(bookID, rawURL)
if cacheKey == "" {
c.String(http.StatusBadGateway, "音频缓存不可用")
return
}
rangeHeader := strings.TrimSpace(c.GetHeader("Range"))
unlock, locked := readerAudioStreamLocks.LockWait(cacheKey, readerAudioLockWait)
if locked {
defer unlock()
// 排队期间前一个请求可能已经把整章下完:直接下发本地文件,不重复回源。
if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok {
defer f.Close()
serveCachedReaderAudio(c, f, info, rawURL)
return
}
}
if rangeHeader != "" && serveReaderAudioRange(c, svc, book, bookID, rawURL, headers, rangeHeader, locked) {
return
}
if !locked {
proxyReaderAudio(c, svc, book, bookID, rawURL, headers, rangeHeader)
return
}
// 播放器拖进度时会带 Range。已缓存的文件才谈得上 Range;未缓存时直通
// 只能从 0 发,否则客户端拿到的区间与它请求的对不上。
canStream := c.Request.Method == http.MethodGet &&
rangeHeader == "" &&
!svc.Reader.HasPartialRemoteAudio(bookID, rawURL)
if canStream {
streamReaderAudioInto(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)
}
// serveReaderAudioRange 区间直通:把客户端请求的那一段从上游边下边发。
//
// persist 表示调用方已拿到写锁,可以顺手把字节补进分片(落盘后下次拖动/重播
// 直接读本地)。返回 false 表示这次没法按区间处理(多区间、后缀区间、上游不认
// Range 且总长未知等),调用方应退回整段路径。
func serveReaderAudioRange(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
rangeHeader string,
persist bool,
) bool {
start, end, ok := parseReaderAudioRange(rangeHeader)
if !ok {
return false
}
// 分片里已经躺着 [0, partLen) 的字节:起点在分片内就先把这段发出去,再从
// partLen 处回源续下。起点比分片还靠后说明中间有空洞,拼不上,只能从起点
// 重新取,并且不落盘(否则分片会被写成带洞的文件)。
var partFile *os.File
partLen := int64(0)
upstreamStart := start
if f, size, okPart := svc.Reader.PartialRemoteAudio(bookID, rawURL); okPart {
partFile = f
partLen = size
if partLen > start {
upstreamStart = partLen
}
}
// 收尾时关掉分片句柄:Windows 下「文件还开着就 rename」会被共享冲突挡住,
// 提升分片为正式缓存之前必须先关。
defer func() {
if partFile != nil {
_ = partFile.Close()
}
}()
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
fmt.Sprintf("bytes=%d-", upstreamStart), headers, false)
if err != nil || out == nil || out.resp == nil {
if svc.Log != nil && err != nil {
svc.Log.Warn("reader 音频区间直通失败", zap.String("book", bookID), zap.Error(err))
}
return false
}
resp := out.resp
total := int64(-1)
switch resp.StatusCode {
case http.StatusPartialContent:
crStart, crTotal, crOK := reader.ParseContentRange(resp.Header.Get("Content-Range"))
if !crOK || crTotal <= 0 || crStart != upstreamStart {
drainReaderMediaBody(resp)
return false
}
total = crTotal
case http.StatusOK:
// 上游忽略了 Range,整段从头给:把前缀读掉丢弃,后面的字节与分片仍然
// 对得上(前缀正好也是 [0, upstreamStart)),所以落盘可以照旧。
if resp.ContentLength <= 0 {
drainReaderMediaBody(resp)
return false
}
if upstreamStart > 0 {
if _, copyErr := io.CopyN(io.Discard, resp.Body, upstreamStart); copyErr != nil {
drainReaderMediaBody(resp)
return false
}
}
total = resp.ContentLength
case http.StatusRequestedRangeNotSatisfiable:
// 分片比对象还长(对象换过):丢掉分片走整段路径重来。
drainReaderMediaBody(resp)
return false
default:
drainReaderMediaBody(resp)
return false
}
if total <= 0 || start >= total {
drainReaderMediaBody(resp)
if total > 0 {
c.Header("Content-Range", fmt.Sprintf("bytes */%d", total))
c.Status(http.StatusRequestedRangeNotSatisfiable)
return true
}
return false
}
serveEnd := end
if serveEnd < 0 || serveEnd >= total {
serveEnd = total - 1
}
wantLen := serveEnd - start + 1
prefixLen := int64(0)
if partFile != nil && partLen > start {
prefixLen = partLen - start
if prefixLen > wantLen {
prefixLen = wantLen
}
}
// 响应头必须一次写全:播放器据此决定后面要不要等更多字节。
contentType := readerAudioContentType(rawURL)
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer := c.Writer
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Accept-Ranges", "bytes")
writer.Header().Set("Cache-Control", "private, max-age=604800")
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, serveEnd, total))
writer.Header().Set("Content-Length", strconv.FormatInt(wantLen, 10))
writer.WriteHeader(http.StatusPartialContent)
writer.Flush()
written := int64(0)
if prefixLen > 0 {
n, copyErr := io.CopyN(writer, io.NewSectionReader(partFile, start, prefixLen), prefixLen)
written += n
if copyErr != nil {
drainReaderMediaBody(resp)
if svc.Log != nil {
svc.Log.Warn("reader 音频区间下发失败", zap.String("book", bookID), zap.Error(copyErr))
}
panic(http.ErrAbortHandler)
}
}
// 前缀发完就不需要读句柄了。必须先关掉:Windows 下文件还开着时 rename
// 会被共享冲突挡住,下面的「提升分片为正式缓存」就会静默失败。
_ = partFile.Close()
partFile = nil
if written >= wantLen {
// 客户端要的区间完全落在本地分片里:这一轮不必再回源。
drainReaderMediaBody(resp)
return true
}
// 剩余字节边下边发;拿得到写锁时同时补进分片,落满后转成完整缓存。
var partWriter *os.File
if persist && partLen >= start {
if f, size, appendErr := svc.Reader.AppendRemoteAudioPartial(bookID, rawURL); appendErr == nil {
if size == partLen {
partWriter = f
} else {
_ = f.Close()
}
}
}
sink := io.Writer(writer)
if partWriter != nil {
sink = io.MultiWriter(writer, partWriter)
}
copied, copyErr := io.CopyBuffer(sink, io.LimitReader(resp.Body, wantLen-written), make([]byte, 32<<10))
written += copied
_ = resp.Body.Close()
if partWriter != nil {
_ = partWriter.Close()
if copyErr == nil {
// 分片正好落满整章时提升为正式缓存(长度对不上时内部会拒绝)。
svc.Reader.PromoteRemoteAudioPartial(bookID, rawURL, total)
}
}
if copyErr == nil && written >= wantLen {
return true
}
if svc.Log != nil {
svc.Log.Warn("reader 音频区间中断",
zap.String("book", bookID),
zap.Int64("written", written),
zap.Int64("want", wantLen),
zap.Error(copyErr))
}
// 已经发出去的字节收不回来:只能中止响应让播放器判「不完整」,分片留给下次续传。
panic(http.ErrAbortHandler)
}
// proxyReaderAudio 纯透传:不落盘、不排队,把上游字节原样转给播放器。
//
// 用在「同一条音轨已经有别的请求在写分片」时:旧实现让第二个请求排队等前一个
// 下载结束,而首播直通会一直持锁到客户端读完整章,播放器重试因此可能干等几十秒。
func proxyReaderAudio(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
rangeHeader string,
) {
out, err := fetchReaderMediaResilient(c.Request.Context(), svc, book, rawURL,
rangeHeader, headers, rangeHeader == "")
if err != nil || out == nil || out.resp == nil {
if svc.Log != nil && err != nil {
svc.Log.Warn("reader 音频透传失败", zap.String("book", bookID), zap.Error(err))
}
c.String(http.StatusBadGateway, "音频拉取失败")
return
}
resp := out.resp
defer func() { _ = resp.Body.Close() }()
contentType := readerAudioContentType(rawURL)
if upstreamType := strings.TrimSpace(resp.Header.Get("Content-Type")); strings.HasPrefix(strings.ToLower(upstreamType), "audio/") {
contentType = upstreamType
}
writer := c.Writer
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Accept-Ranges", "bytes")
writer.Header().Set("Cache-Control", "private, max-age=604800")
if v := resp.Header.Get("Content-Range"); v != "" {
writer.Header().Set("Content-Range", v)
}
if resp.ContentLength > 0 {
writer.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
writer.WriteHeader(resp.StatusCode)
if len(out.head) > 0 {
if _, writeErr := writer.Write(out.head); writeErr != nil {
panic(http.ErrAbortHandler)
}
}
streamReaderMediaBody(writer, resp.Body)
}
// drainReaderMediaBody 丢掉剩余响应体并关闭连接(让上游把连接收回去)。
func drainReaderMediaBody(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
_ = resp.Body.Close()
}
// parseReaderAudioRange 解析客户端的单区间 Range 头("bytes=0-" / "bytes=100-199")。
//
// 多区间与后缀区间("bytes=-500" 要总长才能换算)不在这里处理:播放器不会这么
// 发,真遇到就退回磁盘路径,由 http.ServeContent 负责。
func parseReaderAudioRange(header string) (start, end int64, ok bool) {
header = strings.TrimSpace(header)
const prefix = "bytes="
if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
return 0, 0, false
}
spec := strings.TrimSpace(header[len(prefix):])
if spec == "" || strings.Contains(spec, ",") {
return 0, 0, false
}
dash := strings.IndexByte(spec, '-')
if dash <= 0 {
return 0, 0, false
}
start, err := strconv.ParseInt(strings.TrimSpace(spec[:dash]), 10, 64)
if err != nil || start < 0 {
return 0, 0, false
}
endPart := strings.TrimSpace(spec[dash+1:])
if endPart == "" {
return start, -1, true
}
end, err = strconv.ParseInt(endPart, 10, 64)
if err != nil || end < start {
return 0, 0, false
}
return start, end, true
}
// streamReaderAudioInto 首播直通:一边落盘一边把字节写给客户端(调用方已持写锁)。
//
// 直接写 c.Writer 而不是先落盘再 ServeContent,是为了让第一个字节尽早到达。
// 下载失败时客户端已经收到部分字节,只能中止响应(见下面的 panic),
// 不能改成 502 —— 状态码早就发出去了。
func streamReaderAudioInto(
c *gin.Context,
svc *service.Container,
book *model.ReaderBook,
bookID, rawURL string,
headers map[string]string,
) {
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
@@ -1007,6 +1673,33 @@ type readerMediaResult struct {
// 不改变原有行为;
// - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案,
// 而不是让播放器对着必然失败的地址反复重试。
//
// 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,
@@ -1034,7 +1727,10 @@ 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, &readerMediaRejectedError{
status: http.StatusUnauthorized,
message: "音源签名失效(上游 401 拒绝),请稍后重试或换源",
}
}
return out, nil
}
@@ -1042,7 +1738,7 @@ func fetchReaderMedia(
_, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16))
_ = out.resp.Body.Close()
if refreshErr != nil {
return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr)
return nil, fmt.Errorf("%w: %v", errReaderMediaStaleTicket, refreshErr)
}
retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody)
if retryErr != nil {
@@ -1050,14 +1746,84 @@ func fetchReaderMedia(
}
if retried.rejected {
_ = retried.resp.Body.Close()
return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源")
return nil, &readerMediaRejectedError{
status: retried.resp.StatusCode,
refreshed: true,
message: "音源换票后仍返回非媒体内容,请稍后重试或换源",
}
}
if reader.MediaNeedsRefresh(retried.resp.StatusCode) {
return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", 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 连接,同一章
@@ -1255,7 +2021,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"))
@@ -1264,7 +2051,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
@@ -1277,13 +2065,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
@@ -1293,7 +2082,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
+17 -7
View File
@@ -30,10 +30,12 @@ import (
//
// include_media=0 只返回历史行本身。客户端判断「继续播放」指向哪一集时只
// 需要 media_id/position_ms;带上整条 media 会让 300 条的响应从 ~40KB 涨到
// ~420KB,手机链路上光传输就要十几秒,详情页的续播解析会被它拖住。
// ~420KB。更重要的是服务端不再 hydrate:不查整条 media、也不为挂载条目
// 逐条请求上游 Emby(见 PlaybackService.RecentHistoryWithoutMedia)。
func historyListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
userID := toString(uid)
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 || limit > 500 {
limit = 50
@@ -44,19 +46,27 @@ func historyListHandler(svc *service.Container) gin.HandlerFunc {
includeMedia = parsed
}
}
items, err := svc.Playback.RecentHistory(c.Request.Context(), toString(uid), limit)
ctx := c.Request.Context()
visibility := mediaVisibilityForRequest(c, svc)
if !includeMedia {
// 可见性由服务层用窄列查询判定,handler 不再需要 media。
items, err := svc.Playback.RecentHistoryWithoutMedia(ctx, userID, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
return
}
items, err := svc.Playback.RecentHistory(ctx, userID, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visibility := mediaVisibilityForRequest(c, svc)
filtered := make([]service.HistoryItem, 0, len(items))
for _, item := range items {
// 可见性仍然按 media 判断,只是决定要不要把它写进响应。
// 可见性按 media 判断:媒体已删除/远端取不到时仍然保留这条历史。
if item.Media == nil || visibility.Allows(item.Media) {
if !includeMedia {
item.Media = nil
}
filtered = append(filtered, item)
}
}
+74
View File
@@ -175,3 +175,77 @@ func TestHistoryListCanOmitEmbeddedMedia(t *testing.T) {
t.Fatalf("include_media=0 must not embed media: %s", lean.Body.String())
}
}
// TestHistoryListIncludeMediaFalseSkipsHydration 保证 include_media=0 时服务端
// 真的不 hydrate media:响应里只有历史行(客户端按 media_id 匹配续播目标)。
func TestHistoryListIncludeMediaFalseSkipsHydration(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PlaybackHistory{}, &model.Media{}); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "影片",
Path: "/media/1.mkv",
}).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
for i, mediaID := range []string{"media-1", "media-deleted"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "history-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 30_000,
DurationMs: 120_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatal(err)
}
}
repos := repository.New(db)
svc := &service.Container{
Log: zap.NewNop(),
Repo: repos,
Playback: service.NewPlaybackService(zap.NewNop(), repos),
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(middleware.CtxUserID, "user-1")
c.Next()
})
router.GET("/watch-history", historyListHandler(svc))
for _, path := range []string{
"/watch-history?limit=10&include_media=0",
"/watch-history?limit=10&include_media=false",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d, body = %s", path, rec.Code, rec.Body.String())
}
var rows []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &rows); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
// 媒体已删除的历史也保留:客户端只按 media_id 判断有没有记录。
if len(rows) != 2 {
t.Fatalf("%s rows = %d, want 2 (%s)", path, len(rows), rec.Body.String())
}
for _, row := range rows {
if _, ok := row["media"]; ok {
t.Fatalf("%s should not embed media: %v", path, row)
}
if row["media_id"] == "" || row["media_id"] == nil {
t.Fatalf("%s missing media_id: %v", path, row)
}
}
}
}
+6
View File
@@ -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 存储的
+196
View File
@@ -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
}
+107 -5
View File
@@ -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)
}
+7 -5
View File
@@ -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)
}
+16
View File
@@ -179,6 +179,22 @@ func (s *MediaService) libraryCardsObjectKey(libraryID string, visibility MediaV
return s.seriesCardsCacheKey(libraryID, visibility) + ":obj"
}
// 版本分组对象的缓存 key。
//
// 详情页的「同片多版本」以前每次都现算:条目没有 series_id / tmdb / douban /
// thetvdb 时走不到收窄分支,会直接把这个库最多 5000 行整行读出来再逐条算版本
// 键(实测 5000 行的库单次 ~180ms,且没有缓存)。同一个版本组的结果只随扫描/
// 刮削变化,所以按「合并库集合 + 版本组键」缓存,配合 media: 前缀一起被
// invalidateMediaCache 清掉。
func (s *MediaService) versionSiblingsCacheKey(libraryIDs []string, groupKey string) string {
libs := append([]string(nil), libraryIDs...)
sort.Strings(libs)
return "media:obj:version-siblings:" + hashObjectCacheKey([]string{
strings.Join(libs, ","),
groupKey,
})
}
func (s *MediaService) invalidateMediaCache(ctx context.Context) {
if s != nil && s.cache != nil {
s.cache.DeletePrefix(ctx, "media:")
+30
View File
@@ -255,6 +255,35 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
if len(libraryIDs) == 0 {
libraryIDs = []string{media.LibraryID}
}
// 同一个版本组的结果对组内任一成员都成立,而且只随扫描/刮削变化:缓存它
// 才能让「逐部点开详情」的浏览不再每次都重扫该库最多 5000 行。
cacheKey := s.versionSiblingsCacheKey(libraryIDs, key)
if s.cache != nil {
if obj, ok := s.cache.GetObject(cacheKey); ok {
if cached, ok := obj.([]model.Media); ok {
// GetObject 返回的对象此后视为不可变,调用方(详情页)可能重排/追加
// 切片,所以拷贝一份再交出去。
out := make([]model.Media, len(cached))
copy(out, cached)
return out, nil
}
}
}
matched, err := s.loadVersionSiblings(ctx, media, libraryIDs, key)
if err != nil {
return nil, err
}
if s.cache != nil {
s.cache.SetObject(cacheKey, matched, s.derivedReadCacheTTL())
}
out := make([]model.Media, len(matched))
copy(out, matched)
return out, nil
}
// loadVersionSiblings 是版本分组的慢路径(可能读全库),结果由
// listVersionSiblings 缓存。
func (s *MediaService) loadVersionSiblings(ctx context.Context, media *model.Media, libraryIDs []string, key string) ([]model.Media, error) {
filter := repository.MediaQueryFilter{IncludeNSFW: true}
// 成人条目按番号分组,而番号常常只存在于路径/标题里:本地 NFO 来源的分片
// 没有 douban_id/thetvdb_id(MetaTube 刮削出来的那几个才有)。若仍按外部
@@ -263,6 +292,7 @@ func (s *MediaService) listVersionSiblings(ctx context.Context, media *model.Med
// 直接在该库范围内比对版本键。未刮削但文件名带番号的分片同理:同一部片的
// 各分片元数据来源不一致(刮削 vs pending),收窄后标题也对不上。
var candidates []model.Media
var err error
narrowed := false
hasAdultCode := mediaAdultGroupCode(*media) != "" ||
canonicalAdultGroupCode(AdultCodeFromMediaPath(media.Path)) != ""
+73
View File
@@ -787,3 +787,76 @@ func TestMediaVersionGroupKeyDistinguishesHalfEpisode(t *testing.T) {
t.Fatalf("plain episode key = %q, want suffix 1:11", plainKey)
}
}
// TestGetMediaItemCachesVersionSiblings 覆盖详情页最贵的一次查询:
// 条目没有外部 ID 时会整库读最多 5000 行来找同片版本,而结果只随扫描/刮削
// 变化。这里断言第二次打开详情走缓存(删掉候选行后仍能拿到同一组版本),
// 并且调用方修改返回切片不会污染缓存。
func TestGetMediaItemCachesVersionSiblings(t *testing.T) {
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
repos := repository.New(db)
lib := model.Library{Name: "电影", Path: "/movies", Type: "movie"}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
now := time.Now()
rows := []model.Media{
{
Base: model.Base{ID: "v-1080", CreatedAt: now, UpdatedAt: now},
LibraryID: lib.ID,
Title: "影片 2024 1080p",
Path: "/movies/影片.2024.1080p.mkv",
Width: 1920,
Height: 1080,
Year: 2024,
},
{
Base: model.Base{ID: "v-2160", CreatedAt: now.Add(time.Hour), UpdatedAt: now.Add(time.Hour)},
LibraryID: lib.ID,
Title: "影片 2024 2160p",
Path: "/movies/影片.2024.2160p.mkv",
Width: 3840,
Height: 2160,
Year: 2024,
},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SetRuntimeCache(NewRuntimeCacheService(&config.Config{}, zap.NewNop()))
t.Cleanup(func() { _ = svc.cache.Close() })
item, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(item.Versions) != 2 {
t.Fatalf("versions = %d, want 2: %#v", len(item.Versions), item.Versions)
}
// 把数据删掉:如果第二次还去查库,就只会剩下代表条目自己。
if err := db.Where("id = ?", "v-2160").Delete(&model.Media{}).Error; err != nil {
t.Fatal(err)
}
second, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
if len(second.Versions) != 2 {
t.Fatalf("cached versions = %d, want 2", len(second.Versions))
}
// 调用方乱改返回的切片不能影响缓存内容。
second.Versions[0] = model.Media{Base: model.Base{ID: "hacked"}}
third, err := svc.GetMediaItem(t.Context(), "v-1080")
if err != nil {
t.Fatal(err)
}
for _, v := range third.Versions {
if v.ID == "hacked" {
t.Fatal("caller mutation leaked into the cached version list")
}
}
}
+73
View File
@@ -193,6 +193,79 @@ func (p *PlaybackService) RecentHistory(ctx context.Context, userID string, limi
return items, nil
}
// RecentHistoryWithoutMedia 返回最近观看记录,但完全不 join media。
//
// 详情页解析「继续播放」只需要 media_id/position_ms,带整条 media 会让 300 条
// 响应大一个数量级。旧实现是先用 RecentHistory 把 media 全部查出来(挂载条目
// 还会逐条串行请求上游 Emby),最后才在 handler 里把 media 置空 —— 也就是
// 响应变小了,服务端一点没省。这里直接跳过 hydrate:
//
// - 本地条目:用 (id, library_id, nsfw) 窄列查询判断可见性,不读 overview /
// path / 海报这些大字段;
// - 挂载条目:按挂载媒体库的 ACL 判断,不再发上游请求(上游慢或挂了时,
// 详情页的续播解析不会被它拖住);
// - 记录还在但 media 已删除:保留。与旧行为一致 —— 旧实现 hydrate 失败时
// 同样保留这条历史,客户端只拿 media_id 判断续播目标。
func (p *PlaybackService) RecentHistoryWithoutMedia(ctx context.Context, userID string, limit int, visibility MediaVisibility) ([]HistoryItem, error) {
rows, err := p.repo.History.ListByUser(ctx, userID, limit)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return []HistoryItem{}, nil
}
mediaIDs := make([]string, 0, len(rows))
for i := range rows {
if rows[i].MediaID != "" {
mediaIDs = append(mediaIDs, rows[i].MediaID)
}
}
// 只取可见性判定真正需要的三列。Select 到 model.Media 上是本仓库既有写法
// (见 database/backfillLibraryRootMedia),未选中的字段保持零值。
visibleByID := make(map[string]bool, len(mediaIDs))
if len(mediaIDs) > 0 {
var lean []model.Media
if err := p.repo.DB.WithContext(ctx).Model(&model.Media{}).
Select("id", "library_id", "nsfw").
Where("id IN ?", mediaIDs).
Find(&lean).Error; err != nil {
return nil, err
}
for i := range lean {
visibleByID[lean[i].ID] = visibility.Allows(&lean[i])
}
}
// 挂载库 ACL 每个挂载只解析一次:ResolveMount 只读本地表,不发网络请求。
mountAllowed := make(map[string]bool, 2)
items := make([]HistoryItem, 0, len(rows))
for i := range rows {
mediaID := rows[i].MediaID
if allowed, known := visibleByID[mediaID]; known {
if allowed {
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
continue
}
if p.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, _, _ := DecodeEmbyRemoteID(mediaID)
allowed, resolved := mountAllowed[mountID]
if !resolved {
mount, _, resolveErr := p.remote.ResolveMount(ctx, mountID)
// 挂载解析不出来时无法判定,沿用旧行为保留这条历史。
allowed = resolveErr != nil || mount == nil || EmbyMountLibraryAllowed(visibility, mount)
mountAllowed[mountID] = allowed
}
if !allowed {
continue
}
}
items = append(items, HistoryItem{PlaybackHistory: rows[i]})
}
return items, nil
}
// ─── Favourites ─────────────────────────────────────────────────────────────
// ToggleFavourite flips the favourite flag and reports the new state.
+62
View File
@@ -113,3 +113,65 @@ func TestPlaybackProgressCompletedThreshold(t *testing.T) {
})
}
}
// TestRecentHistoryWithoutMediaSkipsHydration 覆盖详情页用的轻量历史路径:
// 不返回 media,但可见性过滤必须与带 media 的版本一致。
func TestRecentHistoryWithoutMediaSkipsHydration(t *testing.T) {
db := newServiceTestDB(t, &model.PlaybackHistory{}, &model.Media{}, &model.Library{})
repos := repository.New(db)
svc := NewPlaybackService(zap.NewNop(), repos)
seed := func(id, libraryID string, nsfw bool) {
t.Helper()
m := model.Media{
Base: model.Base{ID: id},
LibraryID: libraryID,
Title: "标题 " + id,
Path: "/media/" + id + ".mkv",
NSFW: nsfw,
}
if err := db.Create(&m).Error; err != nil {
t.Fatalf("seed media %s: %v", id, err)
}
}
seed("visible", "lib-ok", false)
seed("adult", "lib-ok", true)
seed("hidden-lib", "lib-hidden", false)
now := time.Now()
for i, mediaID := range []string{"visible", "adult", "hidden-lib", "gone"} {
if err := db.Create(&model.PlaybackHistory{
Base: model.Base{ID: "hist-" + mediaID},
UserID: "user-1",
MediaID: mediaID,
PositionMs: 10_000,
DurationMs: 60_000,
WatchedAt: now.Add(-time.Duration(i) * time.Minute),
}).Error; err != nil {
t.Fatalf("seed history %s: %v", mediaID, err)
}
}
items, err := svc.RecentHistoryWithoutMedia(context.Background(), "user-1", 50, MediaVisibility{
HiddenLibraryIDs: []string{"lib-hidden"},
})
if err != nil {
t.Fatalf("RecentHistoryWithoutMedia: %v", err)
}
got := make([]string, 0, len(items))
for _, item := range items {
if item.Media != nil {
t.Fatalf("expected no media hydration, got %#v", item.Media)
}
got = append(got, item.MediaID)
}
want := []string{"visible", "gone"}
if len(got) != len(want) {
t.Fatalf("media ids = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("media ids = %v, want %v", got, want)
}
}
}
@@ -0,0 +1,730 @@
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
}
}
}
// ─── 区间直通:首播不必等整章下完 ───────────────────────────────────────────
//
// 播放器(media3)取一条 m4a 时会开两次请求:先整段打开嗅探容器,再带
// Range 从目标位置重开。过去「带 Range 的冷缓存请求」只能整章下完再按区间
// 下发,于是每一章的起播都要等 1–4MB 的跨境回源(实测 1.5–35 秒)。下面这
// 三个方法让 handler 能把客户端要的那一段边下边发,同时把字节补进分片。
// PartialRemoteAudio 打开未完成分片(只读),返回文件句柄与当前长度。
//
// 播放器重开同一条音轨时,分片里已经躺着 [0, size) 的字节:区间直通先把这段
// 发出去,再从 size 处回源续下,不必为了一个字节重下整章。
func (s *ReaderService) PartialRemoteAudio(bookID, mediaURL string) (*os.File, int64, bool) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, 0, false
}
f, err := os.Open(path + audioPartialSuffix)
if err != nil {
return nil, 0, false
}
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > audioMediaObjectMaxBytes {
_ = f.Close()
return nil, 0, false
}
return f, info.Size(), true
}
// AppendRemoteAudioPartial 以追加方式打开分片,返回打开那一刻的长度。
//
// 调用方必须核对「返回长度 == 它算出的断点」:对不上说明分片已被别的写入者
// 推进过,此时继续写会把字节落到错误位置,宁可放弃落盘只做纯透传。
func (s *ReaderService) AppendRemoteAudioPartial(bookID, mediaURL string) (*os.File, int64, error) {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" {
return nil, 0, errors.New("音频缓存目录未配置")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, 0, fmt.Errorf("创建音频缓存目录失败: %w", err)
}
f, err := os.OpenFile(path+audioPartialSuffix, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
if err != nil {
return nil, 0, err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, 0, err
}
return f, info.Size(), nil
}
// PromoteRemoteAudioPartial 把已经写满的分片转成正式缓存文件。
//
// 只认「分片长度 == 上游声明的总长」。区间直通是按播放器节奏下发的,中途断开
// 时字节数必然不足;把这种分片当完整缓存下发,播放器会表现为「时长正确但后
// 半段没声音」。
func (s *ReaderService) PromoteRemoteAudioPartial(bookID, mediaURL string, total int64) bool {
path := s.RemoteAudioCachePath(bookID, mediaURL)
if path == "" || total <= 0 {
return false
}
partPath := path + audioPartialSuffix
info, err := os.Stat(partPath)
if err != nil || !info.Mode().IsRegular() || info.Size() != total {
return false
}
if err := os.Rename(partPath, path); err != nil {
return false
}
s.pruneAudioMediaCache()
return true
}
// ParseContentRange 解析 "bytes 100-199/1234" 的起点与总长度。
//
// 导出给 handler 用:区间直通要按上游声明的总长自己组装 206 响应头。
func ParseContentRange(v string) (start, total int64, ok bool) {
return parseContentRange(v)
}
@@ -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)
}
+1 -1
View File
@@ -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)
}
+3 -3
View File
@@ -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,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 ""
}
@@ -129,7 +129,7 @@ func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
ctx := t.Context()
// 首读:拿到第一张票并落缓存
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)
}
@@ -166,7 +166,7 @@ func TestRefreshMediaURLIssuesNewTicket(t *testing.T) {
}
// 缓存要被刷新成新票:再次读章节不应再回源
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
out2, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0, ContentOptions{})
if err != nil {
t.Fatalf("换票后读章节失败: %v", err)
}
@@ -188,7 +188,7 @@ func TestRefreshMediaURLMemoryReusesTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
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)
}
@@ -218,7 +218,7 @@ func TestRefreshMediaURLSkipsNonTrackURL(t *testing.T) {
svc, _, book := prepareTicketBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0); err != nil {
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")
@@ -236,7 +236,7 @@ func TestRefreshMediaURLErrorsWhenSourceRepeatsTicket(t *testing.T) {
svc, srv, book := prepareTicketBook(t)
ctx := t.Context()
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)
}
+209 -68
View File
@@ -1850,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
@@ -1857,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),防止代理被滥用为开放中转。
//
@@ -1945,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)
@@ -1962,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)
}
@@ -3108,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
@@ -3152,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"
@@ -3175,6 +3304,9 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
out.Content = ""
}
}
// 音频/图片地址改写:默认走签名代理(浏览器播放/展示无法带防盗链头);
// 客户端显式请求直连且书源允许时,保留上游地址并下发所需请求头。
s.applyMediaURLs(ctx, book, out, direct)
return out, nil
}
@@ -3260,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 批量取正文的汇总结果。
@@ -3278,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
@@ -3314,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++
@@ -3330,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
@@ -3362,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)
}
+1 -1
View File
@@ -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("空正文应当报错,实际当成功下发了")
}
+16 -11
View File
@@ -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)。
+12 -9
View File
@@ -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
+3 -3
View File
@@ -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 {
+12
View File
@@ -10,6 +10,18 @@ export const historyAPI = {
.get<HistoryItem[]>('/watch-history', { params: { limit } })
.then((r) => r.data),
/**
* 只要历史行、不要内嵌的 media。
*
* 媒体详情页解析「继续播放」只需要 media_id/position_ms;带上 media 会让
* 300 条响应大一个数量级,而且服务端还得为每条去查 media(挂载条目逐条
* 回源)。服务端已经支持 include_media=0 跳过 hydrate。
*/
listRows: (limit = 300) =>
api
.get<HistoryItem[]>('/watch-history', { params: { limit, include_media: false } })
.then((r) => r.data),
stats: () => api.get<HistoryStats>('/watch-history/stats').then((r) => r.data),
continueWatching: (limit = 10) =>
+59
View File
@@ -1,6 +1,7 @@
import {
isJumpIntentKey,
isScrollIntentKey,
shouldReapplyGuardedScroll,
shouldPersistClampedSample,
shouldPersistScrollSample,
shouldRememberScroll,
@@ -100,4 +101,62 @@ check('home allows a long jump', isJumpIntentKey('Home'))
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
// 恢复完成后的看守期:列表重排(普通网格换成虚拟列表)会把容器高度压塌,
// scrollTop 被浏览器夹小;没有用户滚动意图时要把记忆位置贴回去。
check(
'collapsed position inside the guard window is re-applied',
shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
check(
'user scroll during the guard window wins',
!shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: true,
now: 1000,
guardUntil: 3000,
}),
)
check(
'no re-apply after the guard window',
!shouldReapplyGuardedScroll({
current: 0,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 4000,
guardUntil: 3000,
}),
)
check(
'no re-apply while the content is still too short',
!shouldReapplyGuardedScroll({
current: 0,
target: 0,
maxScroll: 0,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
check(
'position already at the target is left alone',
!shouldReapplyGuardedScroll({
current: 2600,
target: 2600,
maxScroll: 30000,
userDriven: false,
now: 1000,
guardUntil: 3000,
}),
)
console.log('useScrollMemory.test.ts ok')
+122 -20
View File
@@ -109,6 +109,35 @@ export function shouldPersistClampedSample(input: {
return true
}
/**
* 恢复完成后的看守时长:列表在这一小段时间里仍可能重排(分页补页让普通网格
* 切换成虚拟列表、海报量高……),内容高度瞬间被压塌时浏览器会把 scrollTop
* 夹小,表现就是“返回列表后跳到顶部”。这段时间内没有用户滚动意图就把记忆
* 位置贴回去。
*/
const RESTORE_GUARD_MS = 3000
/**
* 看守期里哪些 scrollTop 跌落要贴回记忆位置。
*
* 只处理“内容还够长、用户没有滚动意图、位置却掉到记忆位置上方”的跌落;
* 用户自己滚上去(有滚动意图)不干预,内容还没长回原来的高度也不强行回填。
*/
export function shouldReapplyGuardedScroll(input: {
current: number
target: number
maxScroll: number
userDriven: boolean
now: number
guardUntil: number
}): boolean {
const { current, target, maxScroll, userDriven, now, guardUntil } = input
if (now > guardUntil) return false
if (userDriven) return false
if (maxScroll <= 0 || target <= 0) return false
return current < target - 1
}
/**
* 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度,
* 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。
@@ -141,8 +170,15 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
let restoreTimer: number | null = null
let restoreFrame = 0
let restorePumpUntil = 0
// 恢复完成后的看守截止时间:见 RESTORE_GUARD_MS。
let guardUntil = 0
// 看守期里被“用户正在滚动”挡下的那次回填,等滚动意图过期后补做一次。
let guardRetryTimer: number | null = null
let lastSaved = saved
let lastHeight = el.scrollHeight
// 内容容器(`<main>` 里那层宽度约束 div):分页补页、列表切换渲染方式都会
// 改变它的高度,恢复回填与看守都靠它驱动。
const content = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
@@ -183,25 +219,91 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
window.clearTimeout(restoreTimer)
restoreTimer = null
}
if (guardRetryTimer != null) {
window.clearTimeout(guardRetryTimer)
guardRetryTimer = null
}
observer?.disconnect()
observer = null
guardUntil = 0
}
/** 恢复完成后继续看守一小段时间,见 RESTORE_GUARD_MS。 */
const startGuard = (target: number) => {
if (target <= 0) return
guardUntil = performance.now() + RESTORE_GUARD_MS
if (restoreFrame) {
window.cancelAnimationFrame(restoreFrame)
restoreFrame = 0
}
if (restoreTimer != null) window.clearTimeout(restoreTimer)
restoreTimer = window.setTimeout(() => {
restoreTimer = null
stopRestore()
}, RESTORE_GUARD_MS)
}
/**
* 看守期里的塌陷回填:列表重排(普通网格换成虚拟列表、海报重新量高)会让
* 内容瞬间变矮,浏览器随之把 scrollTop 夹小,表现就是“返回列表后跳到顶部”。
* 用户没有滚动意图时把记忆位置贴回去。
*/
const reapplyGuardedScroll = () => {
if (restoring) return
const max = currentMaxScroll()
const target = Math.min(lastSaved, max)
if (
shouldReapplyGuardedScroll({
current: el.scrollTop,
target,
maxScroll: max,
userDriven: userScrolling(),
now: performance.now(),
guardUntil,
})
) {
el.scrollTop = target
return
}
// 这次塌陷正好撞上用户还在滚动:等滚动意图过期后再补做一次,别把它漏掉
// (否则用户滚完停手,列表就停在被夹出来的顶部不动了)。
if (userScrolling() && performance.now() <= guardUntil && guardRetryTimer == null) {
guardRetryTimer = window.setTimeout(() => {
guardRetryTimer = null
reapplyGuardedScroll()
}, USER_SCROLL_INTENT_TTL_MS)
}
}
const onContentSizeChange = () => {
if (restoring) {
tryRestore()
return
}
reapplyGuardedScroll()
}
const finishRestore = () => {
if (!restoring) return
restoring = false
stopRestore()
const current = Math.round(el.scrollTop)
if (!sampleIsTrustworthy(current)) {
// 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值,
// 不要把钳制出来的位置写回去。
// 恢复了半天位置却掉到记忆位置上方很远,而且不是用户自己滚上去的:
// 这是浏览器把 scrollTop 夹出来的值(内容被压塌、还在加载占位)。
// 记忆值必须保留,否则下次进来就真的从顶部开始了。
const droppedFar =
saved - current > maxSmoothJumpUp(el.clientHeight) && !userScrolling()
if (droppedFar || !sampleIsTrustworthy(current)) {
stopRestore()
return
}
adoptScrollPosition(current)
startGuard(current)
}
const tryRestore = () => {
if (!restoring) return
// 用户正在滚动:本帧不要回填,把控制权交给他(交给 saveNow 的接管分支)。
if (userScrolling()) return
const max = currentMaxScroll()
const target = Math.min(saved, max)
if (target > 0 && Math.abs(el.scrollTop - target) > 1) {
@@ -230,19 +332,20 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
// 也不能当成“用户接管”。
if (!sampleIsTrustworthy(current)) return
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
if (restoring) {
// 恢复途中用户自己滚了:一律以用户为准(哪怕一次跳得很远,也是他真实的
// 滚轮 / 触摸 / 翻页操作)。位置仍要看住:列表随后切换渲染方式时容器
// 高度还是会塌,塌了要贴回“用户自己选的位置”。
if (!userDriven || Math.abs(current - saved) <= 1) return
restoring = false
adoptScrollPosition(current)
startGuard(current)
return
}
if (restoring) {
if (!userDriven || Math.abs(current - saved) <= 1) return
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
// 逻辑继续和用户抢滚动条。
restoring = false
stopRestore()
adoptScrollPosition(current)
// 非恢复状态下,一瞬间大幅向上跳(虚拟列表挂载量高、浏览器钳制)只有按键
// 翻页 / 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
const upJump = lastSaved > current ? lastSaved - current : 0
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
return
}
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
@@ -276,13 +379,12 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
if (saved <= 0) {
el.scrollTop = 0
} else {
if (typeof ResizeObserver !== 'undefined' && content) {
observer = new ResizeObserver(onContentSizeChange)
observer.observe(content)
}
tryRestore()
if (restoring) {
const content = el.firstElementChild
if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) {
observer = new ResizeObserver(() => tryRestore())
observer.observe(content)
}
restorePumpUntil = performance.now() + 1000
restoreFrame = window.requestAnimationFrame(pumpRestore)
restoreTimer = window.setTimeout(finishRestore, 10000)
+44 -19
View File
@@ -8,10 +8,15 @@ import type { Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
// 超过该数量后切换为虚拟滚动:DOM 只挂载视口内的卡片,大库不再线性劣化。
// 判定必须用“库的总量”而不是“已加载条数”:分页补页会把已加载条数推过阈值,
// 若此时才切换渲染方式,整块网格的 DOM 子树会被替换、容器高度瞬间塌陷,
// 浏览器随之把 scrollTop 夹回顶部,滚动记忆恢复得再好也会当场失效。
const VIRTUALIZE_THRESHOLD = 200
type LibraryMediaSectionsProps = {
isSeries: boolean
/** 当前列表的总条数(媒体库总量),用于稳定地决定是否走虚拟滚动。 */
total: number
items: Media[]
seriesCards: SeriesCard[]
selectedSeries: SeriesCard | null
@@ -25,6 +30,7 @@ type LibraryMediaSectionsProps = {
export function LibraryMediaSections({
isSeries,
total,
items,
seriesCards,
selectedSeries,
@@ -35,15 +41,22 @@ export function LibraryMediaSections({
cardActions,
onSeriesClick,
}: LibraryMediaSectionsProps) {
// 渲染方式在整次浏览里保持不变:总量超过阈值就一开始就用虚拟列表。
const virtualizeByTotal = total > VIRTUALIZE_THRESHOLD
return (
<>
{!isSeries && items.length > 0 && (
<MediaGrid count={items.length} renderItem={(index) => {
const media = items[index]
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
}} />
<MediaGrid
count={items.length}
virtualize={virtualizeByTotal}
renderItem={(index) => {
const media = items[index]
// renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮,
// 配合 memo,父级无关状态变化不再级联重渲染全部卡片。
return <MediaCard key={media.id} media={media} renderActions={cardActions} />
}}
/>
)}
{!isSeries && items.length === 0 && (
@@ -51,17 +64,21 @@ export function LibraryMediaSections({
)}
{isSeries && seriesCards.length > 0 && !selectedSeries && (
<MediaGrid count={seriesCards.length} renderItem={(index) => {
const series = seriesCards[index]
return (
<SeriesCardItem
key={series.key}
series={series}
cardActions={cardActions}
onSeriesClick={onSeriesClick}
/>
)
}} />
<MediaGrid
count={seriesCards.length}
virtualize={virtualizeByTotal}
renderItem={(index) => {
const series = seriesCards[index]
return (
<SeriesCardItem
key={series.key}
series={series}
cardActions={cardActions}
onSeriesClick={onSeriesClick}
/>
)
}}
/>
)}
{isSeries && seriesCards.length === 0 && !loading && (
@@ -94,8 +111,16 @@ const SeriesCardItem = memo(function SeriesCardItem({
)
})
function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) {
if (count <= VIRTUALIZE_THRESHOLD) {
function MediaGrid({
count,
virtualize,
renderItem,
}: {
count: number
virtualize: boolean
renderItem: (index: number) => ReactNode
}) {
if (!virtualize) {
return <div className={MEDIA_GRID_CLASS}>{Array.from({ length: count }, (_, index) => renderItem(index))}</div>
}
return <VirtualMediaGrid totalCount={count} renderItem={renderItem} />
+11
View File
@@ -29,6 +29,7 @@ import { LibrarySeriesDetailSection } from './LibrarySeriesDetailSection'
import { useLibraryData } from './useLibraryData'
import { useLibraryScanStatus } from './useLibraryScanStatus'
import { useLibrarySeriesSelection } from './useLibrarySeriesSelection'
import { useSeriesEpisodeResume } from './useSeriesEpisodeResume'
import { useLibraryAdminActions } from './useLibraryAdminActions'
import { usePermission } from '../hooks/usePermission'
import { useFavourites } from '../hooks/useFavourites'
@@ -214,6 +215,13 @@ export function LibraryPage() {
setSelectedSeason,
onClearSeriesState: () => setSeriesMetadataEditOpen(false),
})
// 抽屉里的「继续播放」:在整剧所有集里挑最近看过的一条(代表条目可能是 OVA)。
const { resume: seriesResume, progressById: seriesEpisodeProgress } = useSeriesEpisodeResume(
selectedSeries?.key ?? '',
selectedSeries?.rep ?? null,
selectedSeriesAllEpisodes,
)
const selectedSeriesScrapeMedia = selectedSeriesEpisodes.find((media) => !isTheatricalFeature(media))
?? selectedSeries?.rep
?? null
@@ -332,6 +340,7 @@ export function LibraryPage() {
<LibraryMediaSections
isSeries={isSeries}
total={total}
items={displayedItems}
seriesCards={displayedSeriesCards}
selectedSeries={selectedSeries}
@@ -349,6 +358,8 @@ export function LibraryPage() {
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
allEpisodes={selectedSeriesEpisodes}
resume={seriesResume}
episodeProgress={seriesEpisodeProgress}
loadingEpisodes={loadingSeriesEpisodes}
playbackFrom={`${location.pathname}${location.search}`}
isAdmin={role === 'admin'}
+28 -4
View File
@@ -8,10 +8,12 @@ import { MediaFavouriteButton } from '../components/MediaFavouriteButton'
import type { Media } from '../types'
import { isTheatricalFeature, seriesTitle, type SeriesCard } from '../utils/groupSeries'
import { episodeNumberValue } from '../utils/episodeNumber'
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
type LibrarySeriesDetailHeaderProps = {
series: SeriesCard
allEpisodes: Media[]
resume: MediaDetailResume | null
playbackFrom: string
isAdmin: boolean
canFavorite: boolean
@@ -32,6 +34,7 @@ type LibrarySeriesDetailHeaderProps = {
export function LibrarySeriesDetailHeader({
series,
allEpisodes,
resume,
playbackFrom,
isAdmin,
canFavorite,
@@ -51,6 +54,9 @@ export function LibrarySeriesDetailHeader({
const tvEpisodes = allEpisodes.filter((media) => !isTheatricalFeature(media))
const theatricalCount = allEpisodes.length - tvEpisodes.length
const firstEpisode = firstPlayableEpisode(tvEpisodes)
// 看过的剧先给「继续播放 · 第N集 · mm:ss」,指向真正看的那一集;
// 没有可用进度时保持原来的「从第一集开始播放」。
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
return (
<>
@@ -90,14 +96,32 @@ export function LibrarySeriesDetailHeader({
</p>
<div className="flex flex-wrap gap-2">
{firstEpisode && (
{resumeTarget ? (
<>
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Link to={`/play/${resumeTarget.mediaId}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
{continueButtonLabel(resumeTarget)}
</Link>
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
<Link
to={`/play/${resumeTarget.mediaId}?restart=1`}
state={{ from: playbackFrom }}
className="btn-outline inline-flex"
title="忽略续播进度,从 0 开始播放"
>
从头播放
</Link>
<ExternalPlayerButton mediaId={resumeTarget.mediaId} label="外部播放器播放" />
</>
) : (
firstEpisode && (
<>
<Link to={`/play/${firstEpisode.id}`} state={{ from: playbackFrom }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={firstEpisode.id} label="外部播放器播放" />
</>
)
)}
{canFavorite && (
<MediaFavouriteButton
@@ -2,6 +2,7 @@ import { AnimatePresence, motion } from 'framer-motion'
import type { Media } from '../types'
import type { SeriesCard } from '../utils/groupSeries'
import type { EpisodeProgress, MediaDetailResume } from './mediaDetailResumeModel'
import { LibrarySeriesDetailHeader } from './LibrarySeriesDetailHeader'
import { LibrarySeriesEpisodes } from './LibrarySeriesEpisodes'
@@ -16,6 +17,8 @@ type LibrarySeriesDetailSectionProps = {
selectedSeason: number | null
visibleEpisodes: Media[]
allEpisodes: Media[]
resume: MediaDetailResume | null
episodeProgress: Map<string, EpisodeProgress>
loadingEpisodes: boolean
playbackFrom: string
isAdmin: boolean
@@ -42,6 +45,8 @@ export function LibrarySeriesDetailSection({
selectedSeason,
visibleEpisodes,
allEpisodes,
resume,
episodeProgress,
loadingEpisodes,
playbackFrom,
isAdmin,
@@ -73,6 +78,7 @@ export function LibrarySeriesDetailSection({
<LibrarySeriesDetailHeader
series={selectedSeries}
allEpisodes={allEpisodes}
resume={resume}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
canFavorite={canFavorite}
@@ -95,6 +101,7 @@ export function LibrarySeriesDetailSection({
selectedEpisodes={selectedEpisodes}
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
progressById={episodeProgress}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange}
@@ -111,6 +118,7 @@ type LibrarySeriesEpisodesPanelProps = {
selectedEpisodes: SeasonEpisodes[]
selectedSeason: number | null
visibleEpisodes: Media[]
progressById: Map<string, EpisodeProgress>
playbackFrom: string
isAdmin: boolean
onSeasonChange: (season: number) => void
@@ -122,6 +130,7 @@ function LibrarySeriesEpisodesPanel({
selectedEpisodes,
selectedSeason,
visibleEpisodes,
progressById,
playbackFrom,
isAdmin,
onSeasonChange,
@@ -134,6 +143,7 @@ function LibrarySeriesEpisodesPanel({
selectedEpisodes={selectedEpisodes}
selectedSeason={selectedSeason}
visibleEpisodes={visibleEpisodes}
progressById={progressById}
playbackFrom={playbackFrom}
isAdmin={isAdmin}
onSeasonChange={onSeasonChange}
+31
View File
@@ -12,6 +12,11 @@ import {
} from '../utils/groupSeries'
import { formatSize } from './libraryPageModel'
import { formatEpisodeLabel, formatEpisodeNumber } from '../utils/episodeNumber'
import {
formatResumeClock,
progressPercent,
type EpisodeProgress,
} from './mediaDetailResumeModel'
type SeasonGroup = {
season: number
@@ -24,6 +29,8 @@ type LibrarySeriesEpisodesProps = {
selectedSeason: number | null
visibleEpisodes: Media[]
playbackFrom: string
/** 每集(按条目 id)的续播进度,用于卡片上的进度条。 */
progressById?: Map<string, EpisodeProgress>
isAdmin?: boolean
onSeasonChange: (season: number) => void
onManualScrape?: (media: Media) => void
@@ -35,6 +42,7 @@ export function LibrarySeriesEpisodes({
selectedSeason,
visibleEpisodes,
playbackFrom,
progressById,
isAdmin = false,
onSeasonChange,
onManualScrape,
@@ -76,6 +84,7 @@ export function LibrarySeriesEpisodes({
{visibleEpisodes.map((ep) => {
const displayTitle = episodeDisplayTitle(ep, visibleEpisodes)
const versionCount = ep.versions?.length ?? 1
const progress = progressById?.get(ep.id)
return (
<div
key={ep.id}
@@ -117,6 +126,21 @@ export function LibrarySeriesEpisodes({
: formatSize(ep.size_bytes)}
{versionCount > 1 ? ` · ${versionCount} 版本` : ''}
</p>
{progress && (
<div className="mt-1 flex items-center gap-1.5" title={progressTitle(progress)}>
<div className="h-1 w-14 overflow-hidden rounded-full bg-sand-200">
<div
className="h-full rounded-full bg-brand-500"
style={{ width: `${progressPercent(progress)}%` }}
/>
</div>
<span className="text-[10px] font-medium text-brand-600 whitespace-nowrap">
{progress.completed
? '已看完'
: `看到 ${formatResumeClock(progress.positionMs)}`}
</span>
</div>
)}
</div>
</Link>
<div className="flex shrink-0 items-center gap-1">
@@ -142,6 +166,13 @@ export function LibrarySeriesEpisodes({
)
}
function progressTitle(progress: EpisodeProgress): string {
if (progress.completed) return '已看完'
const position = formatResumeClock(progress.positionMs)
const duration = formatResumeClock(progress.durationMs)
return duration ? `看到 ${position} / ${duration}` : `看到 ${position}`
}
function episodeDisplayTitle(ep: Media, siblings: Media[]): string {
let mainTitle = ''
const epTitle = ep.episode_title?.trim()
+2
View File
@@ -33,6 +33,7 @@ export function MediaDetailPage() {
isAdmin={role === 'admin'}
favourite={detail.favourite}
playTargetId={media.id || detail.firstPlayableEpisode?.id}
resume={detail.resume}
scrapeEpisodeArtwork={detail.scrapeEpisodeArtwork}
onToggleFavourite={detail.toggleFavourite}
onScrapeEpisodeArtworkChange={detail.setScrapeEpisodeArtwork}
@@ -53,6 +54,7 @@ export function MediaDetailPage() {
selectedEpisodes={detail.seasonGroups}
selectedSeason={detail.selectedSeason}
visibleEpisodes={detail.visibleEpisodes}
progressById={detail.episodeProgress}
playbackFrom={`/media/${media.id}`}
onSeasonChange={detail.setSelectedSeason}
/>
+21 -2
View File
@@ -10,6 +10,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog'
import { OrganizeMediaDialog } from '../components/OrganizeMediaDialog'
import type { Media } from '../types'
import { isDirectStreamMedia } from './playerPageModel'
import { canContinuePlaying, continueButtonLabel, type MediaDetailResume } from './mediaDetailResumeModel'
import { MediaDetailAdminPanel } from './MediaDetailAdminPanel'
import { MediaDetailPoster } from './MediaDetailArtwork'
import { MediaDetailMetadata } from './MediaDetailMetadata'
@@ -20,6 +21,7 @@ interface MediaDetailPlaybackActionsProps {
favourite: boolean
onToggleFavourite: () => void
playTargetId?: string
resume?: MediaDetailResume | null
}
interface MediaDetailMainContentProps extends MediaDetailPlaybackActionsProps {
@@ -96,8 +98,12 @@ export function MediaDetailPlaybackActions({
favourite,
onToggleFavourite,
playTargetId,
resume,
}: MediaDetailPlaybackActionsProps) {
const targetId = media.id || playTargetId || ''
// 代表条目可能是 OVA/特别篇:有续播记录时优先打开历史命中的那一集(或那集的
// 其它版本),并直接把「看到第几秒」写在按钮上。播放器仍会自己读 /resume。
const resumeTarget = resume && canContinuePlaying(resume) ? resume : null
const targetId = resumeTarget?.mediaId || media.id || playTargetId || ''
return (
<div className="flex flex-wrap items-center gap-2.5">
<Link
@@ -106,9 +112,20 @@ export function MediaDetailPlaybackActions({
className="btn-primary h-11 px-6 shadow-sm"
>
<Play size={16} fill="currentColor" />
<span>立即播放</span>
<span>{resumeTarget ? continueButtonLabel(resumeTarget) : '立即播放'}</span>
</Link>
{resumeTarget && (
<Link
to={`/play/${resumeTarget.mediaId}?restart=1`}
state={{ from: `/media/${media.id}` }}
className="btn-ghost h-11 px-4 text-sm"
title="忽略续播进度,从 0 开始播放"
>
<span>从头播放</span>
</Link>
)}
{!isDirectStreamMedia(media) && (
<Link
to={`/play/${targetId}?mode=hls`}
@@ -143,6 +160,7 @@ export function MediaDetailMainContent({
isAdmin,
favourite,
playTargetId,
resume,
scrapeEpisodeArtwork,
onToggleFavourite,
onScrapeEpisodeArtworkChange,
@@ -167,6 +185,7 @@ export function MediaDetailMainContent({
favourite={favourite}
onToggleFavourite={onToggleFavourite}
playTargetId={playTargetId}
resume={resume}
/>
<MediaVersionSwitcher media={media} />
</div>
+60 -13
View File
@@ -64,6 +64,7 @@ import {
//
// ?mode=hls force HLS even when direct play would work
// ?mode=direct force direct play (default for browser-friendly codecs)
// ?restart=1 忽略服务端续播进度,从 0 开始播(详情页的「从头播放」入口)
//
// ?mode= 只在进入某个媒体时读取一次(媒体详情页的「HLS 兼容转码播放」入口依赖它);
// 播放模式不再写回 URL——写 URL 会让 search 变化触发依赖 setSearchParams 的 effect
@@ -102,6 +103,17 @@ function formatMarkTime(totalSec: number): string {
const CLOUD_HLS_RELOAD_MAX = 3
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
/**
* 逐集切换时保留 ?mode=(HLS/直连是整段会话的选择),但丢掉 ?restart=1:
* 「从头播放」只对用户点的那一集生效,下一集仍按自己的续播进度起播。
*/
function searchWithoutRestart(search: string): string {
const params = new URLSearchParams(search)
params.delete('restart')
const next = params.toString()
return next ? `?${next}` : ''
}
function normalizePlayerVolume(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
@@ -177,6 +189,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[]>([])
@@ -202,6 +219,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('')
@@ -542,6 +565,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)
@@ -1258,7 +1287,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
@@ -1526,7 +1554,7 @@ export function PlayerPage() {
// 起播兜底:上游不回 error 时会一直停在 readyState=0,见 armDirectStallWatchdog。
armDirectStallWatchdog()
}
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
if (hlsUnavailableRef.current && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
}
@@ -1555,7 +1583,6 @@ export function PlayerPage() {
cloudReloadNonce,
cloudWaiting,
hlsSource,
hlsUnavailable,
hlsStartSec,
mediaId,
mode,
@@ -1575,6 +1602,8 @@ export function PlayerPage() {
if (!id) return
setResumePosition(0)
setInitialSeekDone(false)
// 详情页的「从头播放」显式要求忽略续播:不再去读 /resume,直接从 0 起播。
if (new URLSearchParams(window.location.search).get('restart') === '1') return
const controller = new AbortController()
playbackAPI
.getResume(id, controller.signal)
@@ -1590,22 +1619,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
@@ -1696,7 +1742,6 @@ export function PlayerPage() {
// 使用 ref 实时同步进度计算所需的状态,避免每次 hlsStartSec 改变都触发 cleanup 并误上报旧进度
const hlsStartSecRef = useRef(hlsStartSec)
const modeRef = useRef(mode)
useEffect(() => {
hlsStartSecRef.current = hlsStartSec
hlsSourceRef.current = hlsSource
@@ -1824,7 +1869,7 @@ export function PlayerPage() {
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
if (!target) return
navigate(
{ pathname: `/play/${target.id}`, search: location.search },
{ pathname: `/play/${target.id}`, search: searchWithoutRestart(location.search) },
{ replace: true, state: location.state },
)
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
@@ -1906,7 +1951,9 @@ export function PlayerPage() {
navigate(
{
pathname: `/play/${target.id}`,
search: location.search,
// 逐集切换保留 ?mode=,但丢掉 ?restart=1:「从头播放」只对用户点的那一集
// 生效,下一集仍按各自的续播进度起播。
search: searchWithoutRestart(location.search),
},
{ state: location.state },
)
@@ -0,0 +1,122 @@
import {
canContinuePlaying,
continueButtonLabel,
episodeProgressById,
formatResumeClock,
progressPercent,
resolveMediaDetailResume,
} from './mediaDetailResumeModel.ts'
import type { HistoryItem, Media } from '../types'
function check(name: string, cond: boolean) {
if (!cond) throw new Error(`mediaDetailResumeModel: ${name}`)
}
function media(partial: Partial<Media> & { id: string }): Media {
return {
library_id: 'lib-1',
title: '标题',
path: `/media/${partial.id}.mkv`,
size_bytes: 0,
duration_sec: 0,
width: 0,
height: 0,
rating: 0,
year: 2024,
season_num: 0,
episode_num: 0,
scrape_status: 'matched',
tmdb_id: 0,
bangumi_id: 0,
nsfw: false,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...partial,
} as Media
}
function historyRow(
mediaID: string,
positionMs: number,
watchedAt: string,
extra: Partial<HistoryItem> = {},
): HistoryItem {
return {
id: `h-${mediaID}`,
user_id: 'u1',
media_id: mediaID,
position_ms: positionMs,
duration_ms: 1_440_000,
watched_at: watchedAt,
completed: false,
...extra,
} as HistoryItem
}
// ── 电影:直接用本条的进度 ────────────────────────────────────────────────
const movie = media({ id: 'movie-1' })
const movieResume = resolveMediaDetailResume(movie, [], [historyRow('movie-1', 754_000, '2024-05-01T10:00:00Z')])
check('movie resume found', movieResume?.mediaId === 'movie-1')
check('movie label', continueButtonLabel(movieResume!) === '继续播放 · 12:34')
check('movie can continue', canContinuePlaying(movieResume))
// ── 剧集:代表条目是 OVA,续播要指向真正看过的那一集 ──────────────────────
const ova = media({ id: 'ova', episode_num: 0, title: '特别篇' })
const ep1 = media({ id: 'ep1', season_num: 1, episode_num: 1 })
const ep2 = media({ id: 'ep2', season_num: 1, episode_num: 2 })
const ep3 = media({ id: 'ep3', season_num: 1, episode_num: 3 })
const episodes = [ep1, ep2, ep3]
const seriesHistory = [
historyRow('ep1', 600_000, '2024-05-01T09:00:00Z', { completed: true }),
historyRow('ep2', 300_000, '2024-05-02T09:00:00Z'),
historyRow('ep3', 0, '2024-05-03T09:00:00Z'),
historyRow('other-show', 900_000, '2024-05-04T09:00:00Z'),
]
const seriesResume = resolveMediaDetailResume(ova, episodes, seriesHistory)
check('series picks newest watched episode', seriesResume?.mediaId === 'ep2')
check('series label', continueButtonLabel(seriesResume!) === '继续播放 · 第 2 集 · 5:00')
// 命中的是某一集的版本时,打开版本、但集号仍按它属于哪一集显示。
const ep2Uhd = media({ id: 'ep2-uhd', season_num: 1, episode_num: 2 })
const versionedEpisodes = [ep1, { ...ep2, versions: [ep2Uhd] }, ep3]
const versionResume = resolveMediaDetailResume(ova, versionedEpisodes, [
historyRow('ep2-uhd', 120_000, '2024-05-02T09:00:00Z'),
])
check('version resume target', versionResume?.mediaId === 'ep2-uhd')
check('version resume label uses owning episode', versionResume?.media.episode_num === 2)
// ── 不值得续播的情况 ──────────────────────────────────────────────────────
check(
'near-start is not a resume',
!canContinuePlaying(resolveMediaDetailResume(movie, [], [historyRow('movie-1', 1_500, '2024-05-01T10:00:00Z')])),
)
check(
'completed is not a resume',
!canContinuePlaying(
resolveMediaDetailResume(movie, [], [
historyRow('movie-1', 1_400_000, '2024-05-01T10:00:00Z', { completed: true }),
]),
),
)
check('no history', resolveMediaDetailResume(movie, [], []) === null)
check(
'unrelated history is ignored',
resolveMediaDetailResume(movie, [], [historyRow('other', 900_000, '2024-05-01T10:00:00Z')]) === null,
)
// ── 时间格式 ──────────────────────────────────────────────────────────────
check('clock minutes', formatResumeClock(754_000) === '12:34')
check('clock hours', formatResumeClock(3_723_000) === '1:02:03')
check('clock invalid', formatResumeClock(0) === '' && formatResumeClock(Number.NaN) === '')
// ── 每集进度(剧集卡片上的进度条) ────────────────────────────────────────
const progress = episodeProgressById(versionedEpisodes, [
historyRow('ep1', 700_000, '2024-05-01T09:00:00Z', { completed: true }),
historyRow('ep2', 200_000, '2024-05-01T09:10:00Z'),
historyRow('ep2-uhd', 400_000, '2024-05-01T09:20:00Z'),
])
check('episode progress skips untouched episodes', !progress.has('ep3'))
check('episode progress prefers newest version row', progress.get('ep2')?.positionMs === 400_000)
check('completed episode is full', progressPercent(progress.get('ep1')!) === 100)
check('partial episode is capped', progressPercent({ positionMs: 1_430_000, durationMs: 1_440_000, completed: false }) === 99)
check('unknown duration still visible', progressPercent({ positionMs: 10_000, durationMs: 0, completed: false }) === 5)
+168
View File
@@ -0,0 +1,168 @@
import type { HistoryItem, Media } from '../types'
import { formatEpisodeLabel } from '../utils/episodeNumber.ts'
/**
* 媒体详情页的「继续播放」解析。
*
* 服务端存的是「(user, media) → 最近位置」的续播行。详情页要回答两个问题:
* 1. 这部剧最新看到哪一集?(代表条目可能是 OVA/特别篇,不能直接用它自己的进度)
* 2. 那一集看到第几秒?
*
* 因此这里拿到的是「整库最近 N 条历史」,在「本条目 + 全部剧集(含各自的版本)」
* 这个集合里挑 watched_at 最新的一条。历史请求用 include_media=false,服务端
* 不需要 join media,也不会为挂载条目逐条回源。
*/
/** 播放器只在「>2 秒且未看完」时才真正续播,详情页用同一阈值决定按钮文案。 */
export const RESUME_MIN_POSITION_MS = 2000
/** 详情页续播扫描的条数(服务端上限 500)。 */
export const RESUME_SCAN_LIMIT = 300
export interface MediaDetailResume {
/** 实际要打开的媒体 id:命中的那条历史,可能是某集的某个版本。 */
mediaId: string
/** 用来显示集号/标题的条目(命中的那集;电影就是本体)。 */
media: Media
positionMs: number
durationMs: number
completed: boolean
}
export interface EpisodeProgress {
positionMs: number
durationMs: number
completed: boolean
}
function versionIDs(media: Media | null | undefined): string[] {
return (media?.versions ?? []).map((v) => v.id).filter(Boolean)
}
/** 「本条 + 各集 + 各集版本」的 id 集合:续播候选范围。 */
export function mediaDetailScopeIDs(media: Media | null, episodes: Media[]): Set<string> {
const ids = new Set<string>()
const add = (item: Media | null | undefined) => {
if (!item?.id) return
ids.add(item.id)
for (const id of versionIDs(item)) ids.add(id)
}
add(media)
for (const ep of episodes) add(ep)
return ids
}
/** 命中的 history.media_id 属于哪个条目:集优先,命中的是版本时归到它那一集。 */
function ownerOf(mediaID: string, media: Media | null, episodes: Media[]): Media | null {
for (const ep of episodes) {
if (ep.id === mediaID) return ep
if (versionIDs(ep).includes(mediaID)) return ep
}
if (media) {
if (media.id === mediaID) return media
if (versionIDs(media).includes(mediaID)) return media
}
return null
}
function watchedAtScore(row: HistoryItem): number {
const at = Date.parse(row.watched_at)
return Number.isFinite(at) ? at : 0
}
function newestRow(rows: HistoryItem[]): HistoryItem | null {
let best: HistoryItem | null = null
for (const row of rows) {
if (!row || !row.media_id) continue
if (best == null || watchedAtScore(row) > watchedAtScore(best)) best = row
}
return best
}
/**
* 详情页的续播目标;没有可用历史时返回 null(按钮退回「立即播放」)。
*/
export function resolveMediaDetailResume(
media: Media | null,
episodes: Media[],
history: HistoryItem[],
): MediaDetailResume | null {
if (!media) return null
const ids = mediaDetailScopeIDs(media, episodes)
if (ids.size === 0) return null
const rows = history.filter((row) => row?.media_id && ids.has(row.media_id) && row.position_ms > 0)
const best = newestRow(rows)
if (!best) return null
return {
mediaId: best.media_id,
media: ownerOf(best.media_id, media, episodes) ?? media,
positionMs: best.position_ms,
durationMs: best.duration_ms,
completed: Boolean(best.completed),
}
}
/** 是否该显示「继续播放」(与播放器真正会续播的条件保持一致)。 */
export function canContinuePlaying(resume: MediaDetailResume | null): boolean {
if (!resume) return false
return !resume.completed && resume.positionMs > RESUME_MIN_POSITION_MS
}
/** 12:34 / 1:02:03;非法输入返回空串。 */
export function formatResumeClock(positionMs: number): string {
if (!Number.isFinite(positionMs) || positionMs <= 0) return ''
const totalSec = Math.floor(positionMs / 1000)
const hours = Math.floor(totalSec / 3600)
const minutes = Math.floor((totalSec % 3600) / 60)
const seconds = totalSec % 60
const pad = (value: number) => String(value).padStart(2, '0')
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`
}
/** 「继续播放 · 第 3 集 · 12:34」;电影没有集号时只带位置。 */
export function continueButtonLabel(resume: MediaDetailResume): string {
const parts = ['继续播放']
const episode = formatEpisodeLabel(resume.media)
if (episode) parts.push(episode)
const clock = formatResumeClock(resume.positionMs)
if (clock) parts.push(clock)
return parts.join(' · ')
}
/** 每集(含版本)的续播进度,用于剧集卡片上的进度条。 */
export function episodeProgressById(
episodes: Media[],
history: HistoryItem[],
): Map<string, EpisodeProgress> {
const map = new Map<string, EpisodeProgress>()
if (episodes.length === 0 || history.length === 0) return map
const byMediaID = new Map<string, HistoryItem>()
for (const row of history) {
if (row?.media_id && row.position_ms > 0) byMediaID.set(row.media_id, row)
}
for (const ep of episodes) {
const rows: HistoryItem[] = []
for (const id of [ep.id, ...versionIDs(ep)]) {
const row = byMediaID.get(id)
if (row) rows.push(row)
}
const best = newestRow(rows)
if (!best) continue
map.set(ep.id, {
positionMs: best.position_ms,
durationMs: best.duration_ms,
completed: Boolean(best.completed),
})
}
return map
}
/** 进度条宽度(0-100)。时长未知时按「看过」显示一小段,避免整条空着。 */
export function progressPercent(progress: EpisodeProgress): number {
if (progress.completed) return 100
if (progress.durationMs > 0) {
const ratio = (progress.positionMs / progress.durationMs) * 100
return Math.min(99, Math.max(2, ratio))
}
return 5
}
+16 -4
View File
@@ -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 ? '播放中' : '已暂停'} ·
+112 -2
View File
@@ -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
? '音频加载失败,请检查网络或源文件是否还在'
? '网络异常,音源没取到'
: '音频无法播放',
)
})
+6 -6
View File
@@ -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',
},
+33 -4
View File
@@ -3,13 +3,19 @@ import { useLocation, type NavigateFunction } from 'react-router-dom'
import toast from 'react-hot-toast'
import { api } from '../api/client'
import { historyAPI } from '../api/history'
import { mediaAPI } from '../api/library'
import { playbackAPI } from '../api/playback'
import { confirmActionResult } from '../components/confirmAction'
import { useEpisodeArtworkPreference } from '../hooks/useEpisodeArtworkPreference'
import type { Media } from '../types'
import type { HistoryItem, Media } from '../types'
import { seasonSortOrder } from '../utils/groupSeries'
import { episodeNumberValue } from '../utils/episodeNumber'
import {
episodeProgressById,
resolveMediaDetailResume,
RESUME_SCAN_LIMIT,
} from './mediaDetailResumeModel'
import { mediaLibraryBackTarget } from './MediaDetailPageModel'
type MediaDetailLocationState = { from?: string } | null
@@ -26,6 +32,7 @@ interface MediaDetailRefreshParams {
setLoading: Dispatch<SetStateAction<boolean>>
setEpisodes: Dispatch<SetStateAction<Media[]>>
setLoadingEpisodes: Dispatch<SetStateAction<boolean>>
setHistory: Dispatch<SetStateAction<HistoryItem[]>>
}
interface MediaDetailActionsParams {
@@ -43,6 +50,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
const [loading, setLoading] = useState(true)
const [episodes, setEpisodes] = useState<Media[]>([])
const [loadingEpisodes, setLoadingEpisodes] = useState(false)
const [history, setHistory] = useState<HistoryItem[]>([])
const [selectedSeason, setSelectedSeason] = useState<number | null>(null)
const [manualScrapeOpen, setManualScrapeOpen] = useState(false)
@@ -57,6 +65,7 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
setLoading,
setEpisodes,
setLoadingEpisodes,
setHistory,
})
const location = useLocation()
const backFrom = (location.state as MediaDetailLocationState)?.from
@@ -108,6 +117,17 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
return (visibleEpisodes.length > 0 ? visibleEpisodes[0] : episodes[0]) || null
}, [episodes, visibleEpisodes])
// 「继续播放」:在「本条目 + 全部剧集(含版本)」里找最近看过的一条。
// 详情页的代表条目可能是 OVA/特别篇,所以不能只看它自己的进度。
const resume = useMemo(
() => resolveMediaDetailResume(media, episodes, history),
[episodes, history, media],
)
const episodeProgress = useMemo(
() => episodeProgressById(episodes, history),
[episodes, history],
)
const handleMetadataSaved = useCallback(async (next: Media) => {
setMedia(next)
await refresh()
@@ -124,6 +144,8 @@ export function useMediaDetailPageState({ id, navigate }: MediaDetailPageStatePa
visibleEpisodes,
hasEpisodes,
firstPlayableEpisode,
resume,
episodeProgress,
setSelectedSeason,
manualScrapeOpen,
metadataEditOpen,
@@ -146,20 +168,26 @@ function useMediaDetailRefresh({
setLoading,
setEpisodes,
setLoadingEpisodes,
setHistory,
}: MediaDetailRefreshParams): () => Promise<void> {
return useCallback(async () => {
if (!id) return
setLoading(true)
setLoadingEpisodes(true)
try {
// 三个请求并行发出;详情一到就解锁整页渲染,收藏状态与分集列表
// 到达后各自补齐(原先完全串行,首屏要排队等满三个往返)。
// 四个请求并行发出;详情一到就解锁整页渲染,收藏、分集列表、续播历史
// 到达后各自补齐(原先完全串行,首屏要排队等满所有往返)。
const nextMediaPromise = mediaAPI.get(id)
const favouritePromise = playbackAPI.favouriteStatus(id).catch(() => false)
const episodesPromise = mediaAPI
.getEpisodes(id)
.then((r) => r.items ?? [])
.catch(() => [])
// 续播扫描只要历史行(include_media=false):服务端不 join media,
// 300 条也只有几十 KB。
const historyPromise = historyAPI
.listRows(RESUME_SCAN_LIMIT)
.catch(() => [] as HistoryItem[])
const nextMedia = await nextMediaPromise
setMedia(nextMedia)
@@ -169,11 +197,12 @@ function useMediaDetailRefresh({
const episodes = await episodesPromise
setEpisodes(episodes)
setHistory(await historyPromise)
} finally {
setLoading(false)
setLoadingEpisodes(false)
}
}, [id, setFavourite, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
}, [id, setFavourite, setHistory, setLoading, setMedia, setEpisodes, setLoadingEpisodes])
}
function useMediaDetailActions({
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useMemo, useState } from 'react'
import { historyAPI } from '../api/history'
import type { HistoryItem, Media } from '../types'
import {
episodeProgressById,
resolveMediaDetailResume,
RESUME_SCAN_LIMIT,
type EpisodeProgress,
type MediaDetailResume,
} from './mediaDetailResumeModel'
interface SeriesEpisodeResume {
resume: MediaDetailResume | null
progressById: Map<string, EpisodeProgress>
}
/**
* 剧集面板(媒体库抽屉 / 媒体详情)用的续播数据。
*
* 代表条目可能是 OVA/特别篇,因此续播目标必须在「整剧所有集」里挑最近看过
* 的一条;打开抽屉时扫一次整库最近历史即可,只取历史行(include_media=false)。
*/
export function useSeriesEpisodeResume(
seriesID: string,
rep: Media | null,
episodes: Media[],
): SeriesEpisodeResume {
const [history, setHistory] = useState<HistoryItem[]>([])
useEffect(() => {
if (!seriesID) {
setHistory([])
return
}
let cancelled = false
historyAPI
.listRows(RESUME_SCAN_LIMIT)
.then((rows) => {
if (!cancelled) setHistory(rows)
})
.catch(() => {
if (!cancelled) setHistory([])
})
return () => {
cancelled = true
}
}, [seriesID])
const resume = useMemo(
() => resolveMediaDetailResume(rep, episodes, history),
[episodes, history, rep],
)
const progressById = useMemo(() => episodeProgressById(episodes, history), [episodes, history])
return { resume, progressById }
}