feat(reader): P3+P4——音频源与漫画/图片源全链路支持

后端:
- /api/reader/media 签名媒体代理:HMAC-SHA256(jwtSecret, bookID|url) 防开放
  中转滥用;免 JWT(audio/img 元素带不了头)但需有效签名+书籍存在
- 携书源级请求头与 Referer(防盗链)、Range 透传(音频拖动进度 206)、
  m3u8 播放列表分片/密钥地址改写为签名代理(hls.js 无感续播)
- 音频/图片地址按最终页面 URL 绝对化,imageStyle 透传;
  GetContentForBook 将 tracks/images 统一改写为代理地址
前端:
- ReaderAudioPanel(仿 ReadAloudDialog):hls.js 播 m3u8 / <audio> 直链,
  上一章/播放暂停/下一章、进度条拖动、倍速 0.75–2x、按秒记忆进度、
  播完自动下一章、点背景呼出菜单
- ReaderComic:上下滚动 / 左右单页双模式(复用翻页/滚动切换)、
  图片懒加载+失败占位、滚动模式左右点击滚屏/中间呼菜单、
  单页模式点击区翻页、菜单进度条按图片序号、按图片序号记忆进度
- 菜单进度条按内容类型适配(文本页/音频章节/漫画图片序号)
This commit is contained in:
truewhile
2026-09-30 17:12:16 +08:00
parent 9cc21a47c5
commit fa41936095
9 changed files with 714 additions and 49 deletions
+2
View File
@@ -26,6 +26,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
api.GET("/reader/media", readerMediaProxyHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+49
View File
@@ -2,8 +2,10 @@
package handler
import (
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
@@ -329,6 +331,53 @@ func readerDeleteReplaceRuleHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerMediaProxyHandler 音频流/漫画图片签名代理:
// 校验 HMAC 签名 → 携书源防盗链头拉取 → Range 透传(音频拖动)/ m3u8 重写。
func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bookID := c.Query("b")
rawURL, err := svc.Reader.VerifyProxyURL(bookID, c.Query("u"), c.Query("s"))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
if isPlaylist {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
_, _ = io.Copy(c.Writer, resp.Body)
}
}
// readerBookContentHandler 书架维度正文(服务端应用替换净化规则)。
func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
+43
View File
@@ -0,0 +1,43 @@
package reader
import (
"net/url"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
func TestProxyURLSignRoundTrip(t *testing.T) {
s := &ReaderService{cfg: &config.Config{Secrets: config.SecretsConfig{JWTSecret: "test-secret"}}}
const raw = "https://cdn.example.com/audio/ep1.mp3?token=abc"
signed := s.ProxyURL("book-1", raw)
parsed, err := url.Parse(signed)
if err != nil {
t.Fatal(err)
}
q := parsed.Query()
if q.Get("b") != "book-1" {
t.Fatalf("bookID = %q", q.Get("b"))
}
restored, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), q.Get("s"))
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if restored != raw {
t.Fatalf("restored = %q", restored)
}
// 篡改签名必须被拒绝
if _, err := s.VerifyProxyURL(q.Get("b"), q.Get("u"), "00000000000000000000000000000000"); err == nil {
t.Fatal("expected signature rejection")
}
// 换书签必须被拒绝
if _, err := s.VerifyProxyURL("book-2", q.Get("u"), q.Get("s")); err == nil {
t.Fatal("expected cross-book signature rejection")
}
// 已是代理地址的不再二次包裹
if again := s.ProxyURL("book-1", signed); again != signed {
t.Fatalf("double wrap: %q", again)
}
}
+134 -8
View File
@@ -4,11 +4,15 @@ package reader
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"sync"
@@ -728,10 +732,109 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou
// ChapterContent 章节内容(按类型返回文本/音频/图片)。
type ChapterContent struct {
Type string `json:"type"` // text / audio / image
Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(m3u8/直链)
Images []string `json:"images,omitempty"` // 漫画图片列表
Type string `json:"type"` // text / audio / image
Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
}
// ─── 媒体代理(音频流 / 漫画图片,带防盗链头与 HMAC 签名) ──────────────────
// ProxyURL 将书源返回的媒体地址改写为签名代理地址。
// 签名 = HMAC-SHA256(jwtSecret, bookID|url),防止代理被滥用为开放中转。
func (s *ReaderService) ProxyURL(bookID, rawURL string) string {
if rawURL == "" || strings.HasPrefix(rawURL, "/api/") {
return rawURL
}
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(bookID + "|" + rawURL))
sig := hex.EncodeToString(mac.Sum(nil))[:32]
return "/api/reader/media?b=" + url.QueryEscape(bookID) +
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(rawURL)) + "&s=" + sig
}
// VerifyProxyURL 校验签名并还原媒体地址。
func (s *ReaderService) VerifyProxyURL(bookID, encoded, sig string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("媒体地址解码失败")
}
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(bookID + "|" + string(raw)))
expect := hex.EncodeToString(mac.Sum(nil))[:32]
if !hmac.Equal([]byte(expect), []byte(sig)) {
return "", fmt.Errorf("媒体地址签名校验失败")
}
return string(raw), nil
}
// FetchMedia 服务端拉取媒体资源(携带书源级请求头与 Referer,支持 Range 透传)。
// 调用方负责关闭 resp.Body。
func (s *ReaderService) FetchMedia(ctx context.Context, book *model.ReaderBook, rawURL, rangeHeader string) (*http.Response, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
for k, v := range helper.HTTPHeaderPresets() {
httpReq.Header.Set(k, v)
}
// 书源级请求头
if s.repo != nil {
if found, findErr := s.repo.GetSourceByURL(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))
}
}
}
}
if httpReq.Header.Get("Referer") == "" && book.Origin != "" {
httpReq.Header.Set("Referer", strings.TrimSuffix(book.Origin, "/")+"/")
}
if rangeHeader != "" {
httpReq.Header.Set("Range", rangeHeader)
}
return s.http.Do(httpReq)
}
// RewritePlaylist 重写 m3u8 播放列表:分片与密钥地址改写为签名代理地址。
func (s *ReaderService) RewritePlaylist(bookID, playlistURL, text string) string {
base, err := url.Parse(playlistURL)
if err != nil {
return text
}
lines := strings.Split(text, "\n")
for i, line := range lines {
t := strings.TrimSpace(line)
if t == "" {
continue
}
if strings.HasPrefix(t, "#") {
// EXT-X-KEY / EXT-X-MAP 的 URI="..." 属性
if idx := strings.Index(t, `URI="`); idx >= 0 {
rest := t[idx+len(`URI="`):]
if end := strings.Index(rest, `"`); end >= 0 {
inner := rest[:end]
abs := GetAbsoluteURLOf(base, inner)
lines[i] = t[:idx] + `URI="` + s.ProxyURL(bookID, abs) + `"` + rest[end+1:]
}
}
continue
}
lines[i] = s.ProxyURL(bookID, GetAbsoluteURLOf(base, t))
}
return strings.Join(lines, "\n")
}
// GetAbsoluteURLOf 以解析后的 base URL 拼绝对地址(内部工具)。
func GetAbsoluteURLOf(base *url.URL, ref string) string {
parsed, err := url.Parse(strings.TrimSpace(ref))
if err != nil {
return ref
}
return base.ResolveReference(parsed).String()
}
// GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。
@@ -750,6 +853,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
}
var parts []string
url := chapterURL
lastFinalURL := ""
for i := 0; i < maxContentNextPage; i++ {
runner := s.jsRunnerFor(ctx, src, bs, "", 0)
req, err := rule.ParseAnalyzeUrlWithJS(url, "", 0, src.SourceURL, runner)
@@ -764,6 +868,7 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
if err != nil {
return nil, err
}
lastFinalURL = finalURL
ar := s.newRuleAnalyzer(ctx, src, bs, "", 0, body, finalURL)
list, err := ar.GetStringList(SPtr(cr.Content), nil, false)
@@ -786,12 +891,21 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
}
out := &ChapterContent{Content: content}
switch src.Type {
case 1:
case 1: // 音频:逐行地址,按最终页面 URL 绝对化
out.Type = "audio"
out.Tracks = splitURLLines(content)
case 2:
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Tracks = append(out.Tracks, abs)
}
}
case 2: // 漫画/图片
out.Type = "image"
out.Images = splitURLLines(content)
for _, line := range splitURLLines(content) {
if abs := rule.GetAbsoluteURL(lastFinalURL, line); abs != "" {
out.Images = append(out.Images, abs)
}
}
out.ImageStyle = SPtr(cr.ImageStyle)
default:
out.Type = "text"
}
@@ -832,6 +946,11 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
return book, nil
}
// GetBook 按 ID 取书(媒体代理等使用)。
func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
return s.repo.GetBook(ctx, id)
}
// ListBooks 书架列表。
func (s *ReaderService) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
return s.repo.ListBooks(ctx, userID)
@@ -996,6 +1115,13 @@ 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)
}
return out, nil
}