mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 20:26:36 +08:00
258 lines
8.2 KiB
Go
258 lines
8.2 KiB
Go
package reader
|
|
|
|
import (
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
// TestAudioSourceExt 音频地址取扩展名:本地路径、带 query 的远端地址、
|
|
// Windows 盘符(会被 url.Parse 当成单字符 scheme)都要认对。
|
|
func TestAudioSourceExt(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want string
|
|
}{
|
|
{`D:\media\斗破苍穹\001-250\001.wma`, ".wma"},
|
|
{"/media/books/01.WMA", ".wma"},
|
|
{"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x&path=y", ".wma"},
|
|
{"https://cdn.example.com/a/b.mp3?token=1#frag", ".mp3"},
|
|
{"https://cdn.example.com/stream", ""},
|
|
{"/media/books/01.m4a", ".m4a"},
|
|
{"", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := audioSourceExt(tc.in); got != tc.want {
|
|
t.Errorf("audioSourceExt(%q) = %q,期望 %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestNeedsAudioTranscode 只对浏览器确定解不了的格式转码,其余维持直出。
|
|
func TestNeedsAudioTranscode(t *testing.T) {
|
|
need := []string{
|
|
`D:\media\001.wma`,
|
|
"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x",
|
|
"/media/01.asf",
|
|
"/media/01.ape",
|
|
}
|
|
for _, s := range need {
|
|
if !needsAudioTranscode(s) {
|
|
t.Errorf("%q 应需要转码", s)
|
|
}
|
|
}
|
|
direct := []string{
|
|
"/media/01.mp3", "/media/01.m4a", "/media/01.m4b", "/media/01.flac",
|
|
"/media/01.ogg", "/media/01.opus", "/media/01.wav",
|
|
"https://cdn.example.com/stream", "",
|
|
}
|
|
for _, s := range direct {
|
|
if needsAudioTranscode(s) {
|
|
t.Errorf("%q 不该被转码", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAudioTranscodeURLSignature 转码地址的签名可校验、改一处即失效。
|
|
func TestAudioTranscodeURLSignature(t *testing.T) {
|
|
svc := newLocalBookService(t)
|
|
source := `D:\media\斗破苍穹\001-250\001.wma`
|
|
|
|
raw := svc.AudioTranscodeURL("book-1", source)
|
|
if !strings.HasPrefix(raw, "/api/reader/audio/transcode?b=book-1&u=") {
|
|
t.Fatalf("转码地址格式不对: %q", raw)
|
|
}
|
|
encoded, sig := queryParam(t, raw, "u"), queryParam(t, raw, "s")
|
|
got, err := svc.VerifyAudioTranscodeURL("book-1", encoded, sig)
|
|
if err != nil || got != source {
|
|
t.Fatalf("签名校验失败: %v / %q", err, got)
|
|
}
|
|
if _, err := svc.VerifyAudioTranscodeURL("book-1", encoded, "deadbeef"); err == nil {
|
|
t.Fatal("错误签名应校验失败")
|
|
}
|
|
if _, err := svc.VerifyAudioTranscodeURL("book-2", encoded, sig); err == nil {
|
|
t.Fatal("换一本书后签名应失效")
|
|
}
|
|
}
|
|
|
|
// TestAudioTranscodeCachePath 缓存名对(书 + 源地址)稳定且互不冲突。
|
|
func TestAudioTranscodeCachePath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
a := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
|
b := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
|
|
if a != b {
|
|
t.Fatalf("同一输入应得到同一缓存路径: %q vs %q", a, b)
|
|
}
|
|
if !strings.HasSuffix(a, ".mp3") {
|
|
t.Fatalf("缓存文件应为 mp3: %q", a)
|
|
}
|
|
for _, other := range []string{
|
|
audioTranscodeCachePath(dir, "book-2", "/x/001.wma"),
|
|
audioTranscodeCachePath(dir, "book-1", "/x/002.wma"),
|
|
} {
|
|
if other == a {
|
|
t.Fatalf("不同书/不同章节不该共用缓存: %q", other)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEnsureTranscodedAudioWithoutFFmpeg 没装 ffmpeg 时要给出可照做的错误。
|
|
func TestEnsureTranscodedAudioWithoutFFmpeg(t *testing.T) {
|
|
svc := newLocalBookService(t)
|
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
|
|
|
_, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", `D:\media\001.wma`)
|
|
if !errors.Is(err, ErrAudioTranscodeUnavailable) {
|
|
t.Fatalf("err = %v,应包含 ErrAudioTranscodeUnavailable", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "WMA") || !strings.Contains(err.Error(), "ffmpeg") {
|
|
t.Fatalf("错误信息应说明格式与解决办法,实际 %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestEnsureTranscodedAudioUsesCache 已有转码结果时直接命中缓存,不依赖 ffmpeg。
|
|
func TestEnsureTranscodedAudioUsesCache(t *testing.T) {
|
|
svc := newLocalBookService(t)
|
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
|
|
|
source := `D:\media\001.wma`
|
|
dir, err := svc.audioTranscodeDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := audioTranscodeCachePath(dir, "book-1", source)
|
|
if err := os.WriteFile(want, []byte("MP3DATA"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", source)
|
|
if err != nil {
|
|
t.Fatalf("命中缓存时不该报错: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("缓存路径 = %q,期望 %q", got, want)
|
|
}
|
|
data, err := os.ReadFile(got)
|
|
if err != nil || string(data) != "MP3DATA" {
|
|
t.Fatalf("缓存内容被改动了: %q / %v", data, err)
|
|
}
|
|
}
|
|
|
|
// TestBuildFFmpegAudioArgs ffmpeg 参数:输入输出位置正确,丢掉视频流,
|
|
// 强制 mp3 编码,请求头按 key 排序保证可复现。
|
|
func TestBuildFFmpegAudioArgs(t *testing.T) {
|
|
args := buildFFmpegAudioArgs("/in/01.wma", "/out/01.mp3", nil)
|
|
if args[0] != "-hide_banner" || args[len(args)-1] != "/out/01.mp3" {
|
|
t.Fatalf("参数首尾不对: %v", args)
|
|
}
|
|
joined := strings.Join(args, " ")
|
|
for _, want := range []string{"-i /in/01.wma", "-vn", "-map 0:a:0", "-c:a libmp3lame", "-f mp3"} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("参数缺少 %q: %v", want, args)
|
|
}
|
|
}
|
|
|
|
withHeaders := buildFFmpegAudioArgs("u", "o", map[string]string{
|
|
"User-Agent": "mebox", "Cookie": "a=1",
|
|
})
|
|
idx := indexOf(withHeaders, "-headers")
|
|
if idx < 0 {
|
|
t.Fatalf("应带上 -headers: %v", withHeaders)
|
|
}
|
|
headers := withHeaders[idx+1]
|
|
if !strings.HasPrefix(headers, "Cookie: a=1\r\n") || !strings.Contains(headers, "User-Agent: mebox\r\n") {
|
|
t.Fatalf("请求头内容或顺序不对: %q", headers)
|
|
}
|
|
}
|
|
|
|
// TestPruneAudioTranscodeCache 超过上限时按访问时间淘汰最旧的,落到 90%。
|
|
func TestPruneAudioTranscodeCache(t *testing.T) {
|
|
dir := t.TempDir()
|
|
base := time.Now().Add(-time.Hour)
|
|
sizes := []int64{400, 400, 400}
|
|
names := []string{"old.mp3", "mid.mp3", "new.mp3"}
|
|
var total int64
|
|
for i, name := range names {
|
|
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, sizes[i]), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ts := base.Add(time.Duration(i) * time.Minute)
|
|
if err := os.Chtimes(filepath.Join(dir, name), ts, ts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
total += sizes[i]
|
|
}
|
|
// 上限设为总量的一半:必须淘汰
|
|
pruneAudioTranscodeCache(dir, total/2)
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, "old.mp3")); !os.IsNotExist(err) {
|
|
t.Errorf("最旧的缓存应被删除,stat err = %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "new.mp3")); err != nil {
|
|
t.Errorf("最新的缓存应保留: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLocalAudioTrackWithoutFFmpeg localAudioTrack 在需要转码但没 ffmpeg 时,
|
|
// 直接给出可读的错误,而不是返回一个注定播不了的地址。
|
|
func TestLocalAudioTrackWithoutFFmpeg(t *testing.T) {
|
|
svc := newLocalBookService(t)
|
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
|
|
|
|
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
|
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.wma`}
|
|
_, transcoding, err := svc.localAudioTrack(book, ch)
|
|
if err == nil {
|
|
t.Fatal("缺 ffmpeg 时应报错")
|
|
}
|
|
if transcoding {
|
|
t.Error("报错时不该标记为转码中")
|
|
}
|
|
if !strings.Contains(err.Error(), "ffmpeg") {
|
|
t.Fatalf("错误信息应提到 ffmpeg: %q", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestLocalAudioTrackPlayableStaysDirect 浏览器能播的格式不转码,仍走本地音频端点。
|
|
func TestLocalAudioTrackPlayableStaysDirect(t *testing.T) {
|
|
svc := newLocalBookService(t)
|
|
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
|
|
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.mp3`}
|
|
|
|
track, transcoding, err := svc.localAudioTrack(book, ch)
|
|
if err != nil {
|
|
t.Fatalf("mp3 不该报错: %v", err)
|
|
}
|
|
if transcoding {
|
|
t.Error("mp3 不该标记为转码中")
|
|
}
|
|
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
|
|
t.Fatalf("mp3 应走本地音频端点: %q", track)
|
|
}
|
|
}
|
|
|
|
// queryParam 取查询参数(签名用例共用)。
|
|
func queryParam(t *testing.T, raw, key string) string {
|
|
t.Helper()
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u.Query().Get(key)
|
|
}
|
|
|
|
func indexOf(items []string, want string) int {
|
|
for i, v := range items {
|
|
if v == want {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|