mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/service/reader"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReaderContentOptions(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
query string
|
||||||
|
want reader.ContentOptions
|
||||||
|
}{
|
||||||
|
{"", reader.ContentOptions{}},
|
||||||
|
{"?media=direct", reader.ContentOptions{MediaDirect: true}},
|
||||||
|
{"?media=proxy", reader.ContentOptions{ForceProxy: true}},
|
||||||
|
{"?audio=native", reader.ContentOptions{NativeAudio: true}},
|
||||||
|
{"?audio=native&media=direct", reader.ContentOptions{NativeAudio: true, MediaDirect: true}},
|
||||||
|
{"?audio=native&media=proxy", reader.ContentOptions{NativeAudio: true, ForceProxy: true}},
|
||||||
|
{"?audio=%20NaTiVe%20&media=%20DiReCt%20", reader.ContentOptions{NativeAudio: true, MediaDirect: true}},
|
||||||
|
{"?audio=native&media=unknown", reader.ContentOptions{NativeAudio: true}},
|
||||||
|
{"?audio=browser&media=proxy", reader.ContentOptions{ForceProxy: true}},
|
||||||
|
{"?audio=unknown&media=direct", reader.ContentOptions{MediaDirect: true}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.query, func(t *testing.T) {
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/reader/books/book/content"+tc.query, nil)
|
||||||
|
if got := readerContentOptions(c); got != tc.want {
|
||||||
|
t.Fatalf("options = %+v, want %+v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2023,19 +2023,23 @@ func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
// readerContentOptions 从查询参数解析正文下发的可选项。
|
// readerContentOptions 从查询参数解析正文下发的可选项。
|
||||||
//
|
//
|
||||||
// 目前只有 `media` 一个维度:
|
|
||||||
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
|
// - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致;
|
||||||
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
|
// - `media=direct`:书源开启直连时保留上游地址,由客户端自取;
|
||||||
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路。
|
// - `media=proxy`:本次强制代理,给客户端直连失败时的退路;
|
||||||
|
// - `audio=native`:由客户端原生解码音频,不走本地有声书的浏览器转码。
|
||||||
|
//
|
||||||
|
// audio 与 media 独立,原生解码同样可以走签名代理。
|
||||||
func readerContentOptions(c *gin.Context) reader.ContentOptions {
|
func readerContentOptions(c *gin.Context) reader.ContentOptions {
|
||||||
|
opts := reader.ContentOptions{
|
||||||
|
NativeAudio: strings.EqualFold(strings.TrimSpace(c.Query("audio")), "native"),
|
||||||
|
}
|
||||||
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
|
switch strings.ToLower(strings.TrimSpace(c.Query("media"))) {
|
||||||
case "direct":
|
case "direct":
|
||||||
return reader.ContentOptions{MediaDirect: true}
|
opts.MediaDirect = true
|
||||||
case "proxy":
|
case "proxy":
|
||||||
return reader.ContentOptions{ForceProxy: true}
|
opts.ForceProxy = true
|
||||||
default:
|
|
||||||
return reader.ContentOptions{}
|
|
||||||
}
|
}
|
||||||
|
return opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息:
|
// 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息:
|
||||||
// - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本)
|
// - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本)
|
||||||
// - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条)
|
// - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条)
|
||||||
|
//
|
||||||
// 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。
|
// 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。
|
||||||
package reader
|
package reader
|
||||||
|
|
||||||
@@ -480,6 +481,13 @@ func (s *ReaderService) BackfillLocalCover(ctx context.Context, book *model.Read
|
|||||||
// LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。
|
// LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。
|
||||||
func (s *ReaderService) LocalChapterContent(
|
func (s *ReaderService) LocalChapterContent(
|
||||||
ctx context.Context, userID, bookID string, chapterIndex int,
|
ctx context.Context, userID, bookID string, chapterIndex int,
|
||||||
|
) (*ChapterContent, error) {
|
||||||
|
return s.localChapterContentWithOptions(ctx, userID, bookID, chapterIndex, ContentOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localChapterContentWithOptions 保留网页默认行为,原生客户端可选择自行解码音轨。
|
||||||
|
func (s *ReaderService) localChapterContentWithOptions(
|
||||||
|
ctx context.Context, userID, bookID string, chapterIndex int, opts ContentOptions,
|
||||||
) (*ChapterContent, error) {
|
) (*ChapterContent, error) {
|
||||||
book, err := s.repo.GetBook(ctx, bookID)
|
book, err := s.repo.GetBook(ctx, bookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -501,12 +509,20 @@ func (s *ReaderService) LocalChapterContent(
|
|||||||
ch := chapters[chapterIndex]
|
ch := chapters[chapterIndex]
|
||||||
// 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针
|
// 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针
|
||||||
if book.Type == 1 {
|
if book.Type == 1 {
|
||||||
track, transcoding, err := s.localAudioTrack(book, ch)
|
track, transcoding, err := s.localAudioTrackWithOptions(book, ch, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 签名代理路径隐藏了 .m3u8 后缀,客户端必须收到 HLS 类型才能选对解复用器。
|
||||||
|
isHLS := looksLikeHLSURL(track)
|
||||||
|
if parsed, parseErr := url.Parse(track); parseErr == nil && parsed.Path == "/api/reader/media" {
|
||||||
|
q := parsed.Query()
|
||||||
|
if original, verifyErr := s.VerifyProxyURL(book.ID, q.Get("u"), q.Get("s")); verifyErr == nil {
|
||||||
|
isHLS = looksLikeHLSURL(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
return &ChapterContent{
|
return &ChapterContent{
|
||||||
Type: "audio", Tracks: []string{track}, Transcoding: transcoding, declaredType: -1,
|
Type: "audio", Tracks: []string{track}, Transcoding: transcoding, IsHLS: isHLS, declaredType: -1,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
text, err := s.readLocalChapter(book, ch)
|
text, err := s.readLocalChapter(book, ch)
|
||||||
@@ -528,6 +544,11 @@ func (s *ReaderService) LocalChapterContent(
|
|||||||
// .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点);
|
// .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点);
|
||||||
// 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。
|
// 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。
|
||||||
func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderChapter) (string, bool, error) {
|
func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderChapter) (string, bool, error) {
|
||||||
|
return s.localAudioTrackWithOptions(book, ch, ContentOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localAudioTrackWithOptions 原生解码只跳过浏览器转码选择,仍使用相同的签名端点。
|
||||||
|
func (s *ReaderService) localAudioTrackWithOptions(book *model.ReaderBook, ch model.ReaderChapter, opts ContentOptions) (string, bool, error) {
|
||||||
target := strings.TrimSpace(ch.Tag)
|
target := strings.TrimSpace(ch.Tag)
|
||||||
if target == "" {
|
if target == "" {
|
||||||
return "", false, fmt.Errorf("章节定位信息损坏,请重新导入该书")
|
return "", false, fmt.Errorf("章节定位信息损坏,请重新导入该书")
|
||||||
@@ -546,7 +567,7 @@ func (s *ReaderService) localAudioTrack(book *model.ReaderBook, ch model.ReaderC
|
|||||||
target = filepath.Join(filepath.Dir(ch.Tag), target)
|
target = filepath.Join(filepath.Dir(ch.Tag), target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if needsAudioTranscode(target) {
|
if !opts.NativeAudio && needsAudioTranscode(target) {
|
||||||
if s.ffmpegBinary() == "" {
|
if s.ffmpegBinary() == "" {
|
||||||
return "", false, transcodeMissingFFmpegError(target)
|
return "", false, transcodeMissingFFmpegError(target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package reader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 原生解码与媒体通道独立:本地文件和 STRM 都保留原始目标及签名,不依赖 ffmpeg。
|
||||||
|
func TestNativeLocalAudioContent(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
target string // 非空时写入 STRM;空则直接导入 WMA
|
||||||
|
}{
|
||||||
|
{name: "local WMA"},
|
||||||
|
{name: "relative STRM WMA", target: "../original.wma"},
|
||||||
|
{name: "remote STRM WMA", target: "https://cdn.example.com/01.wma?token=original"},
|
||||||
|
{name: "STRM playback API WMA", target: "http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=a&pickcode=p"},
|
||||||
|
{name: "remote STRM HLS", target: "https://cdn.example.com/01.m3u8?token=original"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
svc := newLocalBookService(t)
|
||||||
|
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "missing-ffmpeg")
|
||||||
|
dir := t.TempDir()
|
||||||
|
original := filepath.Join(dir, "01.wma")
|
||||||
|
body := "original-wma-bytes"
|
||||||
|
if tc.target != "" {
|
||||||
|
original = tc.target
|
||||||
|
body = tc.target + "\n"
|
||||||
|
}
|
||||||
|
name := "01.wma"
|
||||||
|
if tc.target != "" {
|
||||||
|
name = "01.strm"
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o640); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.target != "" && !isRemoteMediaURL(original) {
|
||||||
|
original = filepath.Clean(filepath.Join(dir, original))
|
||||||
|
}
|
||||||
|
book, err := svc.ImportLocalAudioDir(t.Context(), "u1", dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, opts := range []ContentOptions{
|
||||||
|
{NativeAudio: true},
|
||||||
|
{NativeAudio: true, MediaDirect: true},
|
||||||
|
{NativeAudio: true, ForceProxy: true},
|
||||||
|
} {
|
||||||
|
out, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("native opts=%+v: %v", opts, err)
|
||||||
|
}
|
||||||
|
if out.Type != "audio" || out.Transcoding || out.MediaDirect || len(out.Tracks) != 1 {
|
||||||
|
t.Fatalf("native content = %+v", out)
|
||||||
|
}
|
||||||
|
if out.IsHLS != looksLikeHLSURL(original) {
|
||||||
|
t.Fatalf("HLS flag=%v for %q", out.IsHLS, original)
|
||||||
|
}
|
||||||
|
track := out.Tracks[0]
|
||||||
|
var got string
|
||||||
|
if isRemoteMediaURL(original) {
|
||||||
|
if !strings.HasPrefix(track, "/api/reader/media?") {
|
||||||
|
t.Fatalf("remote target must use signed proxy: %q", track)
|
||||||
|
}
|
||||||
|
got, err = svc.VerifyProxyURL(book.ID, queryParam(t, track, "u"), queryParam(t, track, "s"))
|
||||||
|
if _, badErr := svc.VerifyProxyURL(book.ID, queryParam(t, track, "u"), "bad"); badErr == nil {
|
||||||
|
t.Fatal("tampered proxy signature accepted")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
|
||||||
|
t.Fatalf("local target must use signed audio endpoint: %q", track)
|
||||||
|
}
|
||||||
|
got, err = svc.VerifyLocalAudioURL(book.ID, queryParam(t, track, "p"), queryParam(t, track, "s"))
|
||||||
|
if _, badErr := svc.VerifyLocalAudioURL(book.ID, queryParam(t, track, "p"), "bad"); badErr == nil {
|
||||||
|
t.Fatal("tampered local signature accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil || got != original {
|
||||||
|
t.Fatalf("original target = %q, err=%v, want %q", got, err, original)
|
||||||
|
}
|
||||||
|
batch, err := svc.GetContentBatch(t.Context(), "u1", book.ID, []int{0}, false, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(batch.Items) != 1 || batch.Items[0].Error != "" || batch.Items[0].Transcoding ||
|
||||||
|
len(batch.Items[0].Tracks) != 1 || batch.Items[0].Tracks[0] != track || batch.Items[0].IsHLS != out.IsHLS {
|
||||||
|
t.Fatalf("native batch = %+v", batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := svc.GetContentForBook(t.Context(), "other", book.ID, 0, ContentOptions{NativeAudio: true}); err == nil {
|
||||||
|
t.Fatal("native audio must retain book ownership check")
|
||||||
|
}
|
||||||
|
if !needsAudioTranscode(original) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 网页默认及旧方法仍要求 ffmpeg。
|
||||||
|
if _, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, ContentOptions{}); !errors.Is(err, ErrAudioTranscodeUnavailable) {
|
||||||
|
t.Fatalf("browser without ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.LocalChapterContent(t.Context(), "u1", book.ID, 0); !errors.Is(err, ErrAudioTranscodeUnavailable) {
|
||||||
|
t.Fatalf("legacy local content without ffmpeg: %v", err)
|
||||||
|
}
|
||||||
|
// 这里只选择转码地址,不执行 ffmpeg;用已存在的可执行文件满足 LookPath。
|
||||||
|
svc.cfg.App.FFmpegPath, err = os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := svc.GetContentForBook(t.Context(), "u1", book.ID, 0, ContentOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !out.Transcoding || len(out.Tracks) != 1 || out.Tracks[0] != svc.AudioTranscodeURL(book.ID, original) {
|
||||||
|
t.Fatalf("browser must still select transcode: %+v", out)
|
||||||
|
}
|
||||||
|
batch, err := svc.GetContentBatch(t.Context(), "u1", book.ID, []int{0}, false, ContentOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(batch.Items) != 1 || batch.Items[0].Error != "" || !batch.Items[0].Transcoding {
|
||||||
|
t.Fatalf("browser batch must report transcoding: %+v", batch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1870,6 +1870,9 @@ type ChapterContent struct {
|
|||||||
// 拉取字节再转发。MediaDirect 让客户端(Android App)自己去取上游字节,
|
// 拉取字节再转发。MediaDirect 让客户端(Android App)自己去取上游字节,
|
||||||
// 服务端只负责解析地址与下发请求头。
|
// 服务端只负责解析地址与下发请求头。
|
||||||
type ContentOptions struct {
|
type ContentOptions struct {
|
||||||
|
// NativeAudio 由客户端原生解码音频,本地有声书返回原始音轨,不检查 ffmpeg。
|
||||||
|
// 与 MediaDirect / ForceProxy 独立:只选择解码方式,不改变媒体签名与访问权限。
|
||||||
|
NativeAudio bool
|
||||||
// MediaDirect 请求直连媒体字节。仅对开启了 MediaDirect 的书源生效,
|
// MediaDirect 请求直连媒体字节。仅对开启了 MediaDirect 的书源生效,
|
||||||
// 否则仍返回代理地址,避免把未验证的源推到客户端直连。
|
// 否则仍返回代理地址,避免把未验证的源推到客户端直连。
|
||||||
MediaDirect bool
|
MediaDirect bool
|
||||||
@@ -3245,7 +3248,7 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
|
|||||||
}
|
}
|
||||||
// 本地导入书籍:正文直接从本地文件读,不走书源
|
// 本地导入书籍:正文直接从本地文件读,不走书源
|
||||||
if book.LocalPath != "" {
|
if book.LocalPath != "" {
|
||||||
return s.LocalChapterContent(ctx, userID, bookID, chapterIndex)
|
return s.localChapterContentWithOptions(ctx, userID, bookID, chapterIndex, opts)
|
||||||
}
|
}
|
||||||
chapters, err := s.repo.ListChapters(ctx, bookID)
|
chapters, err := s.repo.ListChapters(ctx, bookID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -3388,6 +3391,7 @@ type ContentBatchItem struct {
|
|||||||
Images []string `json:"images,omitempty"`
|
Images []string `json:"images,omitempty"`
|
||||||
ImageStyle string `json:"image_style,omitempty"`
|
ImageStyle string `json:"image_style,omitempty"`
|
||||||
IsHLS bool `json:"hls,omitempty"`
|
IsHLS bool `json:"hls,omitempty"`
|
||||||
|
Transcoding bool `json:"transcoding,omitempty"`
|
||||||
Comments []ContentComment `json:"comments,omitempty"`
|
Comments []ContentComment `json:"comments,omitempty"`
|
||||||
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
|
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
|
||||||
Cached bool `json:"cached"`
|
Cached bool `json:"cached"`
|
||||||
@@ -3500,6 +3504,7 @@ func batchItemFromContent(index int, out *ChapterContent, applyReplace bool, use
|
|||||||
Images: out.Images,
|
Images: out.Images,
|
||||||
ImageStyle: out.ImageStyle,
|
ImageStyle: out.ImageStyle,
|
||||||
IsHLS: out.IsHLS,
|
IsHLS: out.IsHLS,
|
||||||
|
Transcoding: out.Transcoding,
|
||||||
Comments: out.Comments,
|
Comments: out.Comments,
|
||||||
MediaDirect: out.MediaDirect,
|
MediaDirect: out.MediaDirect,
|
||||||
MediaHeaders: out.MediaHeaders,
|
MediaHeaders: out.MediaHeaders,
|
||||||
|
|||||||
Reference in New Issue
Block a user