From 79943d906745c968d23446d50ce567770660ffb8 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Thu, 5 Nov 2026 17:03:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/reader_content_options_test.go | 38 ++++++ internal/handler/reader_routes.go | 16 ++- internal/service/reader/local.go | 27 +++- internal/service/reader/native_audio_test.go | 127 ++++++++++++++++++ internal/service/reader/reader.go | 7 +- 5 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 internal/handler/reader_content_options_test.go create mode 100644 internal/service/reader/native_audio_test.go diff --git a/internal/handler/reader_content_options_test.go b/internal/handler/reader_content_options_test.go new file mode 100644 index 0000000..947172e --- /dev/null +++ b/internal/handler/reader_content_options_test.go @@ -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) + } + }) + } +} diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 3e4dd39..1160644 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -2023,19 +2023,23 @@ func readerLocalAudioHandler(svc *service.Container) gin.HandlerFunc { // readerContentOptions 从查询参数解析正文下发的可选项。 // -// 目前只有 `media` 一个维度: // - 不传(网页端):媒体全部走服务端签名代理,与历史行为一致; // - `media=direct`:书源开启直连时保留上游地址,由客户端自取; -// - `media=proxy`:本次强制代理,给客户端直连失败时的退路。 +// - `media=proxy`:本次强制代理,给客户端直连失败时的退路; +// - `audio=native`:由客户端原生解码音频,不走本地有声书的浏览器转码。 +// +// audio 与 media 独立,原生解码同样可以走签名代理。 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"))) { case "direct": - return reader.ContentOptions{MediaDirect: true} + opts.MediaDirect = true case "proxy": - return reader.ContentOptions{ForceProxy: true} - default: - return reader.ContentOptions{} + opts.ForceProxy = true } + return opts } // readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。 diff --git a/internal/service/reader/local.go b/internal/service/reader/local.go index 668ce3b..78d90d2 100644 --- a/internal/service/reader/local.go +++ b/internal/service/reader/local.go @@ -4,6 +4,7 @@ // 一样存 reader_chapters,用 ReaderChapter.Tag 记住定位信息: // - TXT:UTF-8 规范化后文件内的字节区间 "start:end"(读章节只读这段,不载整本) // - EPUB:zip 内的 XHTML 条目路径(每次按需解压该条) +// // 网络书籍不受影响:ReaderBook.LocalPath 为空即走书源链路。 package reader @@ -480,6 +481,13 @@ func (s *ReaderService) BackfillLocalCover(ctx context.Context, book *model.Read // LocalChapterContent 读本地书籍的某一章正文(不做书源抓取)。 func (s *ReaderService) LocalChapterContent( 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) { book, err := s.repo.GetBook(ctx, bookID) if err != nil { @@ -501,12 +509,20 @@ func (s *ReaderService) LocalChapterContent( ch := chapters[chapterIndex] // 本地有声书:一章 = 一个音频文件或一个 .strm 播放指针 if book.Type == 1 { - track, transcoding, err := s.localAudioTrack(book, ch) + track, transcoding, err := s.localAudioTrackWithOptions(book, ch, opts) if err != nil { 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{ - Type: "audio", Tracks: []string{track}, Transcoding: transcoding, declaredType: -1, + Type: "audio", Tracks: []string{track}, Transcoding: transcoding, IsHLS: isHLS, declaredType: -1, }, nil } text, err := s.readLocalChapter(book, ch) @@ -528,6 +544,11 @@ func (s *ReaderService) LocalChapterContent( // .strm 指针解析成远端地址(走媒体代理)或本地文件(走本地音频端点); // 浏览器解不了的格式(WMA 等)改走转码端点。第二个返回值表示该章需要转码。 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) if target == "" { 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) } } - if needsAudioTranscode(target) { + if !opts.NativeAudio && needsAudioTranscode(target) { if s.ffmpegBinary() == "" { return "", false, transcodeMissingFFmpegError(target) } diff --git a/internal/service/reader/native_audio_test.go b/internal/service/reader/native_audio_test.go new file mode 100644 index 0000000..f21ad9e --- /dev/null +++ b/internal/service/reader/native_audio_test.go @@ -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) + } + }) + } +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 2744147..33fe496 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -1870,6 +1870,9 @@ type ChapterContent struct { // 拉取字节再转发。MediaDirect 让客户端(Android App)自己去取上游字节, // 服务端只负责解析地址与下发请求头。 type ContentOptions struct { + // NativeAudio 由客户端原生解码音频,本地有声书返回原始音轨,不检查 ffmpeg。 + // 与 MediaDirect / ForceProxy 独立:只选择解码方式,不改变媒体签名与访问权限。 + NativeAudio bool // MediaDirect 请求直连媒体字节。仅对开启了 MediaDirect 的书源生效, // 否则仍返回代理地址,避免把未验证的源推到客户端直连。 MediaDirect bool @@ -3245,7 +3248,7 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st } // 本地导入书籍:正文直接从本地文件读,不走书源 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) if err != nil { @@ -3388,6 +3391,7 @@ type ContentBatchItem struct { Images []string `json:"images,omitempty"` ImageStyle string `json:"image_style,omitempty"` IsHLS bool `json:"hls,omitempty"` + Transcoding bool `json:"transcoding,omitempty"` Comments []ContentComment `json:"comments,omitempty"` // Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。 Cached bool `json:"cached"` @@ -3500,6 +3504,7 @@ func batchItemFromContent(index int, out *ChapterContent, applyReplace bool, use Images: out.Images, ImageStyle: out.ImageStyle, IsHLS: out.IsHLS, + Transcoding: out.Transcoding, Comments: out.Comments, MediaDirect: out.MediaDirect, MediaHeaders: out.MediaHeaders,