diff --git a/docs/reader-plan.md b/docs/reader-plan.md index b65621e..3f7e59a 100644 --- a/docs/reader-plan.md +++ b/docs/reader-plan.md @@ -38,7 +38,7 @@ web/src/ components/reader/ ``` -**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 +**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。 **数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。 @@ -126,8 +126,20 @@ web/src/ | P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 | | P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 | | P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/.` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 | +| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 | +| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 | +| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 | | P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 | +### 实施记录:关于「宽松 @put 触发 panic」的复核 + +前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。 +逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。 +真实缺陷是另外两个并已修复: + +- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失; +- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。 + P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。 ## 8. 风险与对策 diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index 4040fc6..bf42c78 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -34,7 +34,8 @@ - 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。 - 显示项开关:未读章数徽标、更新时间。 - 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。 -- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。 +- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。 +- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。 **书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** - 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。 diff --git a/internal/config/defaults.go b/internal/config/defaults.go index cf49a3d..8776bef 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -52,6 +52,8 @@ func setDefaults(v *viper.Viper) { v.SetDefault("cache.redis_prefix", "mebox") v.SetDefault("cache.media_ttl_seconds", 90) v.SetDefault("cache.emby_latest_ttl_seconds", 300) + v.SetDefault("cache.reader_content_max_size_mb", 1024) + v.SetDefault("cache.reader_content_ttl_hours", 168) v.SetDefault("search.backend", "") v.SetDefault("search.opensearch_url", "") diff --git a/internal/config/normalize.go b/internal/config/normalize.go index 812db71..9ea20fb 100644 --- a/internal/config/normalize.go +++ b/internal/config/normalize.go @@ -62,6 +62,12 @@ func (c *Config) normalize() error { if c.Cache.MediaTTLSeconds < 1 { c.Cache.MediaTTLSeconds = 90 } + if c.Cache.ReaderContentMaxSizeMB < 0 { + c.Cache.ReaderContentMaxSizeMB = 0 + } + if c.Cache.ReaderContentTTLHours < 0 { + c.Cache.ReaderContentTTLHours = 0 + } c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend)) if c.Search.Index == "" { c.Search.Index = "mebox_media" diff --git a/internal/config/types.go b/internal/config/types.go index 70abf3d..f0d8c38 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -133,6 +133,10 @@ type CacheConfig struct { // 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机 // 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。 EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"` + // ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。 + ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"` + // ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。 + ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"` } type SearchConfig struct { diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 19ede15..fb33e0c 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -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 签名( 带不上 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) + } +} diff --git a/internal/helper/http.go b/internal/helper/http.go index 5174038..be09ffb 100644 --- a/internal/helper/http.go +++ b/internal/helper/http.go @@ -17,6 +17,13 @@ import ( // defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。 const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" +// maxDecompressedBody 解压后的响应体上限。 +// +// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以 +// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底, +// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。 +const maxDecompressedBody = 32 << 20 + // DecompressBody 兜底解压响应体(gzip / deflate)。 // // 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip @@ -37,17 +44,20 @@ func DecompressBody(resp *http.Response, data []byte) []byte { return data } defer r.Close() - if out, err := io.ReadAll(r); err == nil { + // 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解, + // 是压缩炸弹的常见放大手段。 + r.Multistream(false) + if out, ok := readLimited(r); ok { return out } case "deflate": // deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。 - if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil { + if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok { return out } if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil { defer zr.Close() - if out, err := io.ReadAll(zr); err == nil { + if out, ok := readLimited(zr); ok { return out } } @@ -55,6 +65,15 @@ func DecompressBody(resp *http.Response, data []byte) []byte { return data } +// readLimited 读取解压流,超过上限返回 ok=false。 +func readLimited(r io.Reader) ([]byte, bool) { + out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1)) + if err != nil || len(out) > maxDecompressedBody { + return nil, false + } + return out, true +} + // StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。 // // 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源 diff --git a/internal/model/model.go b/internal/model/model.go index 6869a9d..ac7a64e 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -65,6 +65,7 @@ func AllModels() []interface{} { &ReaderSourceState{}, &ReaderBook{}, &ReaderChapter{}, + &ReaderContentCache{}, &ReaderReplaceRule{}, &ReaderProfile{}, &ReaderBookGroups{}, diff --git a/internal/model/reader.go b/internal/model/reader.go index 9c3a7d6..b6fcfcb 100644 --- a/internal/model/reader.go +++ b/internal/model/reader.go @@ -98,6 +98,45 @@ type ReaderChapter struct { Tag string `gorm:"type:varchar(255)" json:"tag"` } +// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。 +// +// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex +// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后 +// 逐请求应用,规则改动即时生效。 +// +// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节 +// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新 +// 之后仍然能按同一身份命中或迁移。 +type ReaderContentCache struct { + Base + // OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。 + OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"` + // BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。 + BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"` + // ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。 + ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"` + // ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。 + ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"` + // ChapterIndex 保存时的章节序号(remap 时更新)。 + ChapterIndex int `json:"chapter_index"` + // ContentType text / audio / image。 + ContentType string `gorm:"type:varchar(16)" json:"content_type"` + // SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。 + SourceHash string `gorm:"type:varchar(64)" json:"source_hash"` + // FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。 + FormatVersion int `json:"format_version"` + // SizeBytes 载荷字节数(容量统计用)。 + SizeBytes int64 `json:"size_bytes"` + // AssetCount 音频轨/图片张数(清单类内容的完整性统计)。 + AssetCount int `json:"asset_count"` + // ExpiresAt 过期时间(unix 秒);0 表示不过期。 + ExpiresAt int64 `json:"expires_at"` + // LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。 + LastAccessAt int64 `json:"last_access_at"` + // Hits 命中次数(诊断用)。 + Hits int `json:"hits"` +} + // ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。 type ReaderReplaceRule struct { Base diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index 8c89707..eb95f5c 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -131,6 +131,15 @@ func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bo return &out, nil } +// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。 +// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的 +// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。 +func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) { + var out []model.ReaderBook + err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error + return out, err +} + // CreateBook / UpdateBook / DeleteBook。 func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error { return r.db.WithContext(ctx).Create(b).Error @@ -163,6 +172,17 @@ func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, c }) } +// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。 +// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。 +func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil { + return err + } + return tx.Save(book).Error + }) +} + // ListChapters 按序取章节。 func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) { var out []model.ReaderChapter @@ -170,6 +190,109 @@ func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]m return out, err } +// ─── 正文持久缓存索引 ────────────────────────────────────────────────────── + +// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。 +func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) { + var out model.ReaderContentCache + err := r.db.WithContext(ctx). + Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType). + First(&out).Error + if err != nil { + return nil, err + } + return &out, nil +} + +// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。 +// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。 +func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) { + q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey) + if contentType != "" { + q = q.Where("content_type = ?", contentType) + } + var out model.ReaderContentCache + if err := q.Order("last_access_at DESC").First(&out).Error; err != nil { + return nil, err + } + return &out, nil +} + +// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。 +func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error { + existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType) + if err == nil && existing != nil { + existing.ChapterIdentity = row.ChapterIdentity + existing.ChapterIndex = row.ChapterIndex + existing.SourceHash = row.SourceHash + existing.FormatVersion = row.FormatVersion + existing.SizeBytes = row.SizeBytes + existing.AssetCount = row.AssetCount + existing.ExpiresAt = row.ExpiresAt + existing.LastAccessAt = row.LastAccessAt + return r.db.WithContext(ctx).Save(existing).Error + } + return r.db.WithContext(ctx).Create(row).Error +} + +// TouchContentCache 记录一次命中(更新命中时间与次数)。 +func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error { + return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}). + Where("id = ?", id). + Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error +} + +// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。 +func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) { + var out []model.ReaderContentCache + err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error + return out, err +} + +// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。 +func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) { + rows, err := r.ListContentCacheByBook(ctx, bookKey) + if err != nil || len(rows) == 0 { + return nil, err + } + if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil { + return nil, err + } + return rows, nil +} + +// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。 +func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error +} + +// ListContentCacheExpired 按 TTL 取过期条目。 +func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) { + var out []model.ReaderContentCache + err := r.db.WithContext(ctx). + Where("expires_at > 0 AND expires_at < ?", now). + Order("last_access_at ASC").Limit(limit).Find(&out).Error + return out, err +} + +// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。 +func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) { + var row struct { + Count int64 + Bytes int64 + } + err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}). + Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error + return row.Count, row.Bytes, err +} + +// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。 +func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) { + var out []model.ReaderContentCache + err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error + return out, err +} + // CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。 // 没有目录缓存的书籍不会出现在返回结果里。 func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) { diff --git a/internal/service/reader/image_decode_test.go b/internal/service/reader/image_decode_test.go new file mode 100644 index 0000000..d54a9e2 --- /dev/null +++ b/internal/service/reader/image_decode_test.go @@ -0,0 +1,133 @@ +package reader + +import ( + "strings" + "testing" +) + +// 图片解密(coverDecodeJs / ruleContent.imageDecode)与 HLS 判定链路。 + +// 正文图片解密:书源规则把字节异或 0x55 后返回,服务端解密应还原原文。 +func TestDecodeImageBytesRunsRuleJS(t *testing.T) { + svc, srv, book := prepareCacheTestBook(t) + ctx := t.Context() + + // 动态给书源加上 imageDecode 规则:XOR 0x55 还原。 + raw := strings.Replace( + cacheTestSourceJSON(srv.URL), + `"ruleContent": {"content": "id.content@textNodes"}`, + `"ruleContent": {"content": "id.content@textNodes", "imageDecode": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i 0 { + return parsed.payload + } + return raw +} + // ImportLocalBookFromPath 从服务器上已有的文件导入书籍(TXT / EPUB)。 // // 原地引用,不复制到 data/reader/local;同一个文件重复导入按覆盖更新处理, @@ -590,14 +605,20 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader if !ok { return "", fmt.Errorf("章节定位信息损坏,请重新导入该书") } + if end <= start { + return "", nil + } + // 外部原地引用的非 UTF-8 文件:章节区间是按导入时解码出的 UTF-8 文本算的, + // 必须整文件解码后再切片,直接读原始字节会错位(GBK 一字 2 字节、UTF-16 更多)。 + // 托管副本在导入时已统一落盘 UTF-8,这里不会走这条分支。 + if book.LocalExternal && needsDecodeBeforeSlice(book.Charset) { + return readLocalChapterDecoded(path, start, end) + } f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成 if err != nil { return "", fmt.Errorf("本地书籍文件已丢失: %w", err) } defer f.Close() - if end <= start { - return "", nil - } buf := make([]byte, end-start) if _, err := f.ReadAt(buf, int64(start)); err != nil && err != io.EOF { return "", fmt.Errorf("读取章节失败: %w", err) @@ -605,6 +626,36 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader return strings.TrimSpace(string(buf)), nil } +// needsDecodeBeforeSlice 该字符集的文件是否需要在切片前整文件解码。 +func needsDecodeBeforeSlice(charset string) bool { + switch strings.ToLower(strings.TrimSpace(charset)) { + case "", "utf-8", "utf8": + return false + } + return true +} + +// readLocalChapterDecoded 整文件解码为 UTF-8 后按字节区间取章节(外部引用模式)。 +func readLocalChapterDecoded(path string, start, end int) (string, error) { + f, err := os.Open(path) // #nosec G304 -- path 来自书籍记录,导入时已校验允许根目录 + if err != nil { + return "", fmt.Errorf("本地书籍文件已丢失: %w", err) + } + defer f.Close() + data, err := io.ReadAll(io.LimitReader(f, LocalBookMaxBytes+1)) + if err != nil { + return "", fmt.Errorf("读取章节失败: %w", err) + } + decoded, _ := decodeTextFile(data) + if start > len(decoded) { + return "", nil + } + if end > len(decoded) { + end = len(decoded) + } + return strings.TrimSpace(decoded[start:end]), nil +} + // DeleteLocalBookFile 移出书架时删除本地文件(网络书籍无文件,直接返回)。 // // 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。 diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 0faef71..b68bf75 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -60,6 +60,17 @@ type ReaderService struct { // (见 fetchTocDeduped)。 tocFlightsMu sync.Mutex tocFlights map[string]*tocFlight + + // coverDecodeMu / coverDecodeOrigins 记录声明了 coverDecodeJs 的书源。 + // 搜索结果可能还没入库(没有 ReaderBook),只能按 origin 判断封面是否需要 + // 走解密代理;导入书源时填充,避免每次渲染封面都去查一次库并解析 JSON。 + coverDecodeMu sync.RWMutex + coverDecodeOrigins map[string]bool + + // contentFlightsMu / contentFlights 保护「同一章正在抓正文」的单飞登记表: + // 阅读页与批量预取会并发请求同一章(见 fetchChapterContentShared)。 + contentFlightsMu sync.Mutex + contentFlights map[string]*contentFlight } // NewReaderService 创建服务。 @@ -68,15 +79,46 @@ func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Con // 逐跳收集 Set-Cookie:登录源常把凭证放在跳转链中间那一跳(见 cookies.go)。 enableCookieCapture(client) return &ReaderService{ - cfg: cfg, - log: log, - repo: repos.Reader, - http: client, - crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)), - limiter: newSourceRateLimiter(), + cfg: cfg, + log: log, + repo: repos.Reader, + http: client, + crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)), + limiter: newSourceRateLimiter(), + coverDecodeOrigins: map[string]bool{}, } } +// markSourceCoverDecode 记录某书源是否声明了 coverDecodeJs(导入/更新书源时调用)。 +func (s *ReaderService) markSourceCoverDecode(sourceURL string, enabled bool) { + if s == nil || strings.TrimSpace(sourceURL) == "" { + return + } + s.coverDecodeMu.Lock() + if s.coverDecodeOrigins == nil { + s.coverDecodeOrigins = map[string]bool{} + } + s.coverDecodeOrigins[sourceURL] = enabled + s.coverDecodeMu.Unlock() +} + +// SourceHasCoverDecode 该 origin 的书源是否声明了 coverDecodeJs。 +func (s *ReaderService) SourceHasCoverDecode(origin string) bool { + if s == nil { + return false + } + s.coverDecodeMu.RLock() + enabled := s.coverDecodeOrigins[origin] + s.coverDecodeMu.RUnlock() + if enabled { + return true + } + // 进程重启后 map 是空的(书源已入库):按需回源一次并记住。 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return s.CoverDecodeRule(ctx, origin) != "" +} + // ─── 书源导入与管理 ───────────────────────────────────────────────────────── // ImportSources 导入书源:支持 JSON 数组 / 单对象 / Base64 / 网络URL。 @@ -111,6 +153,8 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er failures = append(failures, fmt.Sprintf("第 %d 条缺少 bookSourceUrl", i+1)) continue } + // 记录该源是否声明封面解密:搜索结果(尚未入库)据此决定封面代理形态。 + s.markSourceCoverDecode(bs.BookSourceURL, strings.TrimSpace(SPtr(bs.CoverDecodeJs)) != "") // 已存在则更新,否则新建(按书源 URL 去重) existing, err := s.repo.GetSourceByURL(ctx, bs.BookSourceURL) now := time.Now() @@ -484,6 +528,14 @@ type sourceSession struct { // 只在登录动作里开启:startBrowserAwait 会阻塞等待用户操作(可达十分钟), // 若在搜索/正文等链路里被书源意外调用,会把普通请求长时间挂住。 browserEnabled bool + // book 是书架维度下的当前书籍(正文/目录执行时设置)。 + // 有它才会注入 book 元数据与 book.getVariable/putVariable 的持久化回调。 + book *model.ReaderBook + // tocBookVars 目录链路的书籍自定义变量(跨翻页共享,flushTocBookVars 时写回)。 + // 目录链路没有具体的 *ReaderBook(一本书可能被多个用户收藏),所以单独承载。 + tocBookVars map[string]string + // tocVarsPutter 目录链路的变量写回回调(按「书源 + 书本地址」定位书架记录)。 + tocVarsPutter func(map[string]string) } // newSession 为指定书源建立执行上下文。 @@ -540,6 +592,26 @@ func (sess *sourceSession) fetch(req *rule.Request) (string, string, int, error) return body, finalURL, code, nil } +// fetchRawBytes 用书源会话拉一份原始字节(queryTTF 的 URL、图片解密前的取图)。 +// 复用书源 header / Cookie / 限速与重试,并且不按 charset 解码(保留原始字节)。 +func (sess *sourceSession) fetchRawBytes(absURL string) ([]byte, error) { + runner := sess.runner("", 0) + req, err := rule.ParseAnalyzeUrlWithJS(absURL, "", 0, sess.srcURL(), runner) + if err != nil { + return nil, err + } + if req.Unsupported != nil { + return nil, req.Unsupported + } + req.Raw = true + sess.applyHeaders(req, runner) + body, _, _, err := sess.fetch(req) + if err != nil { + return nil, err + } + return []byte(body), nil +} + // captureCookies 是否自动保存响应里的 Set-Cookie。 // 对应 legado 的 enabledCookieJar(默认 true):关掉后不再自动累积 Cookie, // 但书源 JS 主动 cookie.setCookie 写入的仍会保存(那是明确意图)。 @@ -577,6 +649,13 @@ func (sess *sourceSession) newAnalyzer(key string, page int, body, finalURL stri // applyBookContext 把书籍/章节上下文注入解析器,供规则 JS 的 book / chapter 对象 // 读取(对应 legado 里 AnalyzeRule 持有 Book / BookChapter 实体)。 +// +// book 传入真实书籍时同时注入 book 元数据与自定义变量,并注册写回回调: +// book.type 与 book.putVariable 的变更会持久化到书架记录(对应 legado 的 +// Book.upVariable),否则书源在规则 JS 里设置的 tone_id / 密钥这类状态, +// 每章都会丢,表现是「同一本书只有第一章能读」。 +// +// 目录链路传 book=nil,改由 tocBookVars 承载变量、tocVarsPutter 负责写回。 func (sess *sourceSession) applyBookContext(ar *rule.AnalyzeRule, bookURL string, book *model.ReaderBook, chapterTitle string, chapterIndex int) { meta := map[string]any{"bookUrl": bookURL, "tocUrl": bookURL} if book != nil { @@ -594,12 +673,51 @@ func (sess *sourceSession) applyBookContext(ar *rule.AnalyzeRule, bookURL string meta["durChapterPos"] = book.DurChapterPos ar.SetBookContext(book.Name, nil) ar.SetBookCustomVars(parseBookVariableMap(book.Variable)) + ar.RegisterBookVariablePutter(func() { + encoded := encodeBookVariableMap(ar.BookCustomVars()) + if encoded == book.Variable { + return + } + book.Variable = encoded + if sess.svc != nil && sess.svc.repo != nil { + if err := sess.svc.repo.UpdateBook(sess.ctx, book); err != nil && sess.svc.log != nil { + sess.svc.log.Warn("reader: 写回书籍变量失败", zap.String("book", book.ID), zap.Error(err)) + } + } + }) + } else if len(sess.tocBookVars) > 0 { + ar.SetBookCustomVars(sess.tocBookVars) + ar.RegisterBookVariablePutter(func() { + if sess.tocVarsPutter != nil { + sess.flushTocBookVars() + } + }) } ar.SetBookMeta(meta) ar.SetChapterContext(chapterTitle, nil) ar.SetChapterIndex(chapterIndex) } +// flushTocBookVars 把目录链路累积的书籍变量写回书架记录(每个翻页批次一次)。 +func (sess *sourceSession) flushTocBookVars() { + if sess == nil || sess.tocVarsPutter == nil || len(sess.tocBookVars) == 0 { + return + } + sess.tocVarsPutter(sess.tocBookVars) +} + +// encodeBookVariableMap 序列化书籍自定义变量;空 map 返回空串(避免把 "{}" 写进列)。 +func encodeBookVariableMap(m map[string]string) string { + if len(m) == 0 { + return "" + } + b, err := json.Marshal(m) + if err != nil { + return "" + } + return string(b) +} + // parseBookVariableMap 解析书架书籍的自定义变量 JSON(对应 legado Book.variableMap)。 func parseBookVariableMap(raw string) map[string]string { if strings.TrimSpace(raw) == "" { @@ -634,6 +752,9 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner { Fetch: func(req *rule.Request) (string, string, int, error) { return sess.fetch(req) }, + FetchBytes: func(absURL string) ([]byte, error) { + return sess.fetchRawBytes(absURL) + }, SourceProps: sess.bs.SourceProps(), Log: func(msg string) { if sess.svc.log != nil { @@ -837,7 +958,16 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri }) } _ = g.Wait() - return mergeSearchResults(hits, key), skipped, nil + merged := mergeSearchResults(hits, key) + // 声明了封面解密的书源:搜索结果封面改走解密代理(尚未入库,按书源 URL 签)。 + for i := range merged { + if len(merged[i].Origins) == 0 { + continue + } + origin := merged[i].Origins[0].Origin + merged[i].CoverURL = s.RewriteBookCover(ctx, "", origin, merged[i].CoverURL) + } + return merged, skipped, nil } // enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。 @@ -1034,6 +1164,11 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo return books, nil } +// applySourceVariables 注入书源 JSON 的 variables 默认值。 +// +// 走 SetSourceDefaults 而不是 SetBookContext:后者是「一次性的书籍级变量」, +// 会让书源默认值永久遮蔽同名的源变量,@put 也会被写进这个用完即弃的 map, +// 导致跨请求丢失(见 AnalyzeRule.Put / Get 的说明)。 func applySourceVariables(ar *rule.AnalyzeRule, bs *BookSource) { if bs.Variables == nil { return @@ -1042,7 +1177,7 @@ func applySourceVariables(ar *rule.AnalyzeRule, bs *BookSource) { for k, v := range bs.Variables { vars[k] = fmt.Sprintf("%v", v) } - ar.SetBookContext("", vars) + ar.SetSourceDefaults(vars) } func firstNonEmpty(vals ...string) string { @@ -1102,15 +1237,18 @@ func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bo if err != nil { return nil, err } - return s.getBookInfoFrom(ctx, src, bs, bookURL) + return s.getBookInfoFrom(ctx, src, bs, bookURL, nil) } -func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL string) (*BookInfo, error) { +// getBookInfoFrom 抓详情。book 非 nil 时(书架维度)把书籍元数据与自定义变量 +// 注入规则 JS,并允许 book.putVariable 的变更持久化回书架记录。 +func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL string, book *model.ReaderBook) (*BookInfo, error) { bir := bs.RuleBookInfo if bir == nil { return nil, fmt.Errorf("书源未配置详情规则") } sess := s.newSession(ctx, src, bs) + sess.book = book defer sess.close() runner := sess.runner("", 0) req, err := rule.ParseAnalyzeUrlWithJS(bookURL, "", 0, sess.srcURL(), runner) @@ -1126,7 +1264,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo return nil, err } ar := sess.newAnalyzer("", 0, body, finalURL) - sess.applyBookContext(ar, bookURL, nil, "", 0) + sess.applyBookContext(ar, bookURL, book, "", 0) info := &BookInfo{BookURL: bookURL, TocURL: bookURL} if initRule := SPtr(bir.Init); initRule != "" { @@ -1213,6 +1351,9 @@ func contentTypeCode(t string) (int, bool) { // GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型 // (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。 +// +// 目录规则里 book.putVariable 写的变量由下游按「书源 + 书本地址」找到书架记录 +// 后持久化(对应 legado 的 Book.upVariable)。 func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) { src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL) if err != nil { @@ -1258,6 +1399,8 @@ type tocFlight struct { func (s *ReaderService) fetchTocDeduped( ctx context.Context, userID string, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string, ) ([]TocChapter, int, error) { + // 目录规则里的 book.putVariable:按「书源 + 书本地址」回写所有持有该书的用户记录。 + putter := s.bookVarsPutterFor(ctx, src.SourceURL, bookURL) effectiveToc := strings.TrimSpace(tocURL) if effectiveToc == "" { effectiveToc = bookURL @@ -1286,7 +1429,7 @@ func (s *ReaderService) fetchTocDeduped( //「context canceled」,也不能让预热在阅读页断开时白跑一半。 fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), tocFetchTimeout) defer cancel() - f.chapters, f.declared, f.err = s.loadTocFromSource(fetchCtx, userID, src, bs, bookURL, tocURL) + f.chapters, f.declared, f.err = s.loadTocFromSource(fetchCtx, userID, src, bs, bookURL, tocURL, putter) close(f.done) s.tocFlightsMu.Lock() @@ -1295,18 +1438,58 @@ func (s *ReaderService) fetchTocDeduped( return f.chapters, f.declared, f.err } +// tocBookTarget 目录链路回写书籍变量时,用来匹配书架记录的页面地址。 +// bookURL 自带 ",{...}" 选项段时取其 URL 部分。 +func tocBookTarget(bookURL string) string { + st, _, ok := rule.FindParamSplit(bookURL) + if ok { + return strings.TrimSpace(bookURL[:st]) + } + return strings.TrimSpace(bookURL) +} + +// bookVarsPutterFor 构造目录链路的书籍变量写回函数:按「书源 + 书本地址」找书架记录。 +// 找不到书架记录(例如详情页还没加入书架)时返回 nil,规则里的改动只活在本次请求内。 +func (s *ReaderService) bookVarsPutterFor(ctx context.Context, sourceURL, bookURL string) func(map[string]string) { + if s.repo == nil { + return nil + } + target := tocBookTarget(bookURL) + if target == "" { + return nil + } + return func(vars map[string]string) { + books, err := s.repo.ListBooksByOriginAndURL(ctx, sourceURL, target) + if err != nil { + return + } + encoded := encodeBookVariableMap(vars) + for i := range books { + if books[i].Variable == encoded { + continue + } + books[i].Variable = encoded + if err := s.repo.UpdateBook(ctx, &books[i]); err != nil && s.log != nil { + s.log.Warn("reader: 写回书籍变量失败", zap.String("book", books[i].ID), zap.Error(err)) + } + } + } +} + // loadTocFromSource 真正抓一次目录,含「给的目录地址抓不到章节就回退详情规则」的兜底。 +// putter 非 nil 时用于把目录规则里 book.putVariable 的变更写回书架记录。 func (s *ReaderService) loadTocFromSource( ctx context.Context, userID string, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string, + putter func(map[string]string), ) ([]TocChapter, int, error) { - chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL) + chapters, declared, err := s.getTocFrom(ctx, src, bs, bookURL, tocURL, putter) if err != nil || len(chapters) == 0 { // 给的目录地址抓不到章节。典型情形是聚合类书源(光遇聚合的 gydetail 信封): // 加书架时只存了 book_url,调用方又把 book_url 当目录地址传进来,规则返回的 // 是书籍详情(没有章节),表现为「目录为空」——漫画就是卡在这里。 // 对齐 legado:目录地址在详情结果里,补走一次详情规则取 tocUrl 再抓,并写回书架。 if detailTocURL := s.deriveTocURLFromDetail(ctx, src, bs, bookURL, tocURL); detailTocURL != "" { - if retried, declared2, retryErr := s.getTocFrom(ctx, src, bs, bookURL, detailTocURL); retryErr == nil && len(retried) > 0 { + if retried, declared2, retryErr := s.getTocFrom(ctx, src, bs, bookURL, detailTocURL, putter); retryErr == nil && len(retried) > 0 { chapters, declared, err = retried, declared2, nil s.persistBookTocURL(ctx, userID, src.SourceURL, bookURL, detailTocURL) } @@ -1336,7 +1519,7 @@ func latestChapterTitleOf(chapters []TocChapter) string { func (s *ReaderService) deriveTocURLFromDetail( ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, currentTocURL string, ) string { - detail, err := s.getBookInfoFrom(ctx, src, bs, bookURL) + detail, err := s.getBookInfoFrom(ctx, src, bs, bookURL, nil) if err != nil || detail == nil { return "" } @@ -1397,7 +1580,9 @@ func (s *ReaderService) applyTocMeta(ctx context.Context, userID, origin, bookUR } } -func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string) ([]TocChapter, int, error) { +// getTocFrom 抓目录。putter 非 nil 时,目录规则里 book.putVariable 的变更会 +// 通过它写回书架记录(对应 legado Book.upVariable)。 +func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, bookURL, tocURL string, putter func(map[string]string)) ([]TocChapter, int, error) { tr := bs.RuleToc if tr == nil || SPtr(tr.ChapterList) == "" { return nil, -1, fmt.Errorf("书源未配置目录规则") @@ -1406,6 +1591,21 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou tocURL = bookURL } sess := s.newSession(ctx, src, bs) + sess.tocVarsPutter = putter + // 目录规则同样要能读 book.getVariable:一本书可能被多个用户收藏, + // 这里合并同源同书的所有书架记录变量(同名键后写覆盖)。 + if putter != nil && s.repo != nil { + if books, err := s.repo.ListBooksByOriginAndURL(ctx, src.SourceURL, tocBookTarget(bookURL)); err == nil { + for i := range books { + for k, v := range parseBookVariableMap(books[i].Variable) { + if sess.tocBookVars == nil { + sess.tocBookVars = map[string]string{} + } + sess.tocBookVars[k] = v + } + } + } + } defer sess.close() declaredType := -1 @@ -1434,6 +1634,7 @@ func (s *ReaderService) getTocFrom(ctx context.Context, src *model.ReaderBookSou } continue } + sess.flushTocBookVars() if declared >= 0 { declaredType = declared } @@ -1541,6 +1742,10 @@ type ChapterContent struct { // Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了), // 首次播放需要等转码完成,之后命中缓存秒开。 Transcoding bool `json:"transcoding,omitempty"` + // IsHLS 为真表示音轨是 HLS 播放列表(m3u8)。 + // 前端据此决定走 hls.js 而不是原生