Files
MeBox/internal/handler/reader_audio_range_test.go
truewhile 9b8ca33279 bug处理
2026-11-01 09:23:58 +08:00

407 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}