优化,阅读问题处理

This commit is contained in:
truewhile
2026-10-10 11:26:47 +08:00
parent 374ee28f01
commit 4ed2edab5b
36 changed files with 3921 additions and 140 deletions
+127 -8
View File
@@ -14,6 +14,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
@@ -77,6 +78,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc))
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
@@ -389,6 +392,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
}
}
@@ -435,6 +442,10 @@ func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
for i := range books {
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
@@ -819,12 +830,37 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
if c.Query("d") == "cover" {
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
if fetchErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
return
}
defer resp.Body.Close()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
return
}
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
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"))
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
@@ -832,13 +868,51 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
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))
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
// 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
return
}
contentType := ct
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -852,6 +926,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
return strings.Contains(ct, "mpegurl")
}
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
func readerBodyIsPlaylist(head []byte, contentType string) bool {
return reader.BodyIsPlaylist(head, contentType)
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
@@ -940,3 +1030,32 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, content)
}
}
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
}
if len(body.ChapterIndexes) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}