mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed2edab5b | |||
| 374ee28f01 | |||
| 3a6b8db0b3 | |||
| b50cb7c31e | |||
| 5f4b613bde | |||
| 67ef9c95be | |||
| 2bf1b27e23 | |||
| 48a1c36a7a | |||
| d7665e4ef0 | |||
| 99916c0b97 | |||
| 6adc3cb3b2 | |||
| 2b2dc697ce | |||
| 8bfdd75d47 | |||
| 1a3248f4bb | |||
| 0be7f8b97e |
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -76,7 +77,15 @@ func main() {
|
||||
fatal("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
|
||||
// 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
|
||||
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
|
||||
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
|
||||
cfg := &config.Config{}
|
||||
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
|
||||
cfg.Cache.CacheDir = cacheDir
|
||||
}
|
||||
svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
|
||||
ctx := context.Background()
|
||||
|
||||
results := make([]*reader.SmokeChainResult, len(sources))
|
||||
|
||||
+13
-1
@@ -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/<bookID>.<txt\|epub>` + 解析目录入 `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. 风险与对策
|
||||
|
||||
@@ -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、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
@@ -95,11 +96,16 @@
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
|
||||
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
|
||||
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// 云 HLS 分片代理的错误映射:
|
||||
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
|
||||
// 而不是当成播放失败直接退回本地转码;
|
||||
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
|
||||
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
|
||||
func newCloud115HLSSessionTestRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := &service.Container{
|
||||
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
|
||||
return router
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
|
||||
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
|
||||
router := newCloud115HLSSessionTestRouter()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
|
||||
WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 499 {
|
||||
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
|
||||
if err == nil || c.Writer.Written() {
|
||||
return
|
||||
}
|
||||
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
|
||||
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
|
||||
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
|
||||
c.Request.Context().Err() != nil {
|
||||
c.AbortWithStatus(499)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
|
||||
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
|
||||
// 而不是直接放弃云端播放退回本地转码。
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
|
||||
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
|
||||
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
|
||||
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
|
||||
default:
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+32
-4
@@ -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 才会自动解压,因此任何来源(预设头、书源
|
||||
@@ -104,10 +123,19 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
//
|
||||
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
|
||||
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
|
||||
// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
|
||||
// `text/html,application/xhtml+xml,...`。
|
||||
//
|
||||
// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
|
||||
// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
|
||||
// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
|
||||
// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
|
||||
// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
|
||||
// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
|
||||
func HTTPHeaderPresets() map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
|
||||
@@ -12,6 +12,18 @@ import (
|
||||
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
|
||||
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
|
||||
|
||||
// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
|
||||
//
|
||||
// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
|
||||
// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
|
||||
// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
|
||||
func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
|
||||
got := HTTPHeaderPresets()["Accept"]
|
||||
if got != "*/*" {
|
||||
t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
|
||||
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
|
||||
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
|
||||
|
||||
@@ -65,6 +65,7 @@ func AllModels() []interface{} {
|
||||
&ReaderSourceState{},
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderContentCache{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,6 +14,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/cloud115"
|
||||
)
|
||||
|
||||
@@ -24,6 +28,10 @@ const (
|
||||
cloud115HLSSessionTTL = 30 * time.Minute
|
||||
cloud115HLSMaxSessions = 256
|
||||
cloud115HLSMaxManifest = 8 << 20
|
||||
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
|
||||
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
|
||||
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
|
||||
cloud115HLSMaxEntries = 20000
|
||||
)
|
||||
|
||||
type cloud115HLSSession struct {
|
||||
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]string
|
||||
next int
|
||||
}
|
||||
|
||||
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
|
||||
//
|
||||
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
|
||||
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
|
||||
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
|
||||
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
|
||||
func cloud115HLSKeyForURL(upstream string) string {
|
||||
sum := sha256.Sum256([]byte(upstream))
|
||||
return hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
|
||||
@@ -302,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
|
||||
}
|
||||
|
||||
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
|
||||
key := cloud115HLSKeyForURL(upstream)
|
||||
session.mu.Lock()
|
||||
session.next++
|
||||
key := "e" + strconv.Itoa(session.next)
|
||||
session.entries[key] = upstream
|
||||
_, known := session.entries[key]
|
||||
if !known && len(session.entries) < cloud115HLSMaxEntries {
|
||||
session.entries[key] = upstream
|
||||
known = true
|
||||
}
|
||||
mediaID := session.MediaID
|
||||
session.mu.Unlock()
|
||||
if !known && p != nil && p.service != nil && p.service.log != nil {
|
||||
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
|
||||
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
|
||||
}
|
||||
|
||||
query := childProxyQuery(rawQuery, mediaID)
|
||||
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
|
||||
|
||||
@@ -25,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
|
||||
if strings.Contains(rewritten, "cpats01.115.com") {
|
||||
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
|
||||
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
|
||||
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
|
||||
t.Fatalf("proxy URL missing: %s", rewritten)
|
||||
}
|
||||
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
|
||||
@@ -36,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
ID: "sess",
|
||||
MediaID: "media-1",
|
||||
entries: map[string]string{},
|
||||
}
|
||||
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
|
||||
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
|
||||
|
||||
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
|
||||
entriesAfterFirst := len(session.entries)
|
||||
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
|
||||
|
||||
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
|
||||
if first != second {
|
||||
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
|
||||
}
|
||||
if len(session.entries) != entriesAfterFirst {
|
||||
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
|
||||
}
|
||||
if entriesAfterFirst != 2 {
|
||||
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
|
||||
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
|
||||
session := &cloud115HLSSession{
|
||||
@@ -151,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
@@ -162,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
|
||||
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
|
||||
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
|
||||
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
|
||||
@@ -192,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
|
||||
MediaID: "media-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
|
||||
next: 1,
|
||||
}
|
||||
proxy.sessions[session.ID] = session
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
|
||||
return hosts
|
||||
}
|
||||
|
||||
// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。
|
||||
func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount {
|
||||
host = normalizeHostKey(host)
|
||||
if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range accounts {
|
||||
lines, _, err := r.LinesOf(&accounts[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if normalizeHostKey(u.Hostname()) == host {
|
||||
return &accounts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。
|
||||
func normalizeHostKey(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前
|
||||
// api_key。host 不属于任何已配置账号时返回 ok=false。
|
||||
func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", false
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil || strings.TrimSpace(cfg.Token) == "" {
|
||||
return "", false
|
||||
}
|
||||
return cfg.Token, true
|
||||
}
|
||||
|
||||
// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号
|
||||
// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录
|
||||
// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。
|
||||
func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", errors.New("远程 Emby 服务不可用")
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host)
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key")
|
||||
}
|
||||
cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.Token, nil
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && attempt == 0 {
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
|
||||
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
|
||||
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
|
||||
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
|
||||
// 新 token 覆盖 api_key。
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
|
||||
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
|
||||
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
|
||||
// 凭据,也避免每个后续请求都重新登录一次。
|
||||
previous := cfg.Token
|
||||
cfg.Token = ""
|
||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||
return fmt.Errorf("认证重试失败: %w", err)
|
||||
}
|
||||
if q != nil {
|
||||
q.Set("api_key", cfg.Token)
|
||||
}
|
||||
master.Token = cfg.Token
|
||||
master.RemoteUserID = cfg.RemoteUserID
|
||||
if cfg.Token != "" && cfg.Token != previous {
|
||||
_ = r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status >= 300 {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" {
|
||||
t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test")
|
||||
if !ok || token != "fake-token" {
|
||||
t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
// The account carries only an api_key — there is nothing to re-authenticate with.
|
||||
if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil {
|
||||
t.Fatal("expected an error when the account has no username/password to re-authenticate with")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」:
|
||||
// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) {
|
||||
const fresh = "fresh-token"
|
||||
logins := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/emby/Users/AuthenticateByName" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logins++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-refresh"},
|
||||
Name: "refresh-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" {
|
||||
t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok)
|
||||
}
|
||||
|
||||
token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if token != fresh {
|
||||
t.Fatalf("refreshed token = %q, want %q", token, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1", logins)
|
||||
}
|
||||
|
||||
// 新 token 必须落库,否则每个请求都要重新登录一次。
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh {
|
||||
t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -65,6 +66,16 @@ type ImageProxy struct {
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
|
||||
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
|
||||
// credential for configured remote-Emby mounts. Image URLs are minted from
|
||||
// whatever token the account had when the payload was built, so a URL can
|
||||
// outlive its api_key; these hooks re-supply the current one and force a
|
||||
// re-login when the mount rejects it.
|
||||
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
|
||||
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
|
||||
remoteEmbyTokenMu sync.Mutex
|
||||
remoteEmbyTokenCache map[string]remoteEmbyImageToken
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
||||
|
||||
// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败
|
||||
// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。
|
||||
var errImageProxyUnauthorized = errors.New("upstream rejected image credentials")
|
||||
|
||||
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
|
||||
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
||||
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
|
||||
@@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
}
|
||||
|
||||
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if lastErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(lastErr, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
// 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着
|
||||
// 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发
|
||||
// 占位图、等人工去改账号配置。
|
||||
if errors.Is(lastErr, errImageProxyUnauthorized) {
|
||||
token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host)
|
||||
if refreshErr != nil {
|
||||
logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr)
|
||||
} else if token != "" {
|
||||
retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if retryErr == nil {
|
||||
return retried, nil
|
||||
}
|
||||
lastErr = retryErr
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
|
||||
@@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
|
||||
}
|
||||
|
||||
// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。
|
||||
// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把
|
||||
// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。
|
||||
func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
|
||||
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
|
||||
// upstream image. A poster can appear in the hero, a shelf and the detail page
|
||||
// at the same time; without this guard every resize variant may fetch the same
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求
|
||||
// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。
|
||||
const remoteEmbyImageTokenTTL = 60 * time.Second
|
||||
|
||||
type remoteEmbyImageToken struct {
|
||||
token string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。
|
||||
// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以
|
||||
// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.refreshRemoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。
|
||||
// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会
|
||||
// 白白查一次账号表。
|
||||
func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return ""
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL {
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
return cached.token
|
||||
}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
|
||||
token, ok := p.remoteEmbyTokenFn(ctx, host)
|
||||
if !ok {
|
||||
token = ""
|
||||
}
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
return token
|
||||
}
|
||||
|
||||
func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) {
|
||||
if p == nil || strings.TrimSpace(host) == "" {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if p.remoteEmbyTokenCache == nil {
|
||||
p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8)
|
||||
}
|
||||
p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或
|
||||
// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。
|
||||
func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := p.refreshRemoteEmbyTokenFn(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby
|
||||
// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再
|
||||
// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。
|
||||
func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil {
|
||||
return
|
||||
}
|
||||
token := p.remoteEmbyTokenForHost(ctx, host)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
q := req.URL.Query()
|
||||
q.Set("api_key", token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的
|
||||
// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值,
|
||||
// 否则挂载 Emby 的封面会一直 401、下发占位图。
|
||||
func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) {
|
||||
const current = "current-token"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != current {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return current, true
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景:
|
||||
// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。
|
||||
func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) {
|
||||
const (
|
||||
stale = "stale-token"
|
||||
fresh = "fresh-token"
|
||||
)
|
||||
var mu sync.Mutex
|
||||
refreshes := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != fresh {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return stale, true
|
||||
})
|
||||
proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) {
|
||||
mu.Lock()
|
||||
refreshes++
|
||||
mu.Unlock()
|
||||
return fresh, nil
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes())
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := refreshes
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("refreshes = %d, want exactly 1 re-auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的
|
||||
// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。
|
||||
func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) {
|
||||
var gotHeader string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("X-Emby-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/img/cover.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotHeader != "" {
|
||||
t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
|
||||
p.applyRemoteEmbyAuth(ctx, req, host)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return remoteImageFetchResult{}, errImageProxyUnauthorized
|
||||
}
|
||||
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
||||
|
||||
@@ -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<src.length;i++){ out[i] = src[i] ^ 0x55; } out"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
rule := svc.ImageDecodeRule(ctx, book)
|
||||
if rule == "" {
|
||||
t.Fatal("imageDecode 规则未解析")
|
||||
}
|
||||
|
||||
plain := []byte("PNG-PLAIN-BYTES")
|
||||
encoded := make([]byte, len(plain))
|
||||
for i, b := range plain {
|
||||
encoded[i] = b ^ 0x55
|
||||
}
|
||||
decoded, err := svc.DecodeImageBytes(ctx, book, "https://img.example.com/a.png", false, encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("解密失败: %v", err)
|
||||
}
|
||||
if string(decoded) != string(plain) {
|
||||
t.Fatalf("解密结果 = %q,期望 %q", decoded, plain)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有规则时零开销直通(返回原始字节)。
|
||||
func TestDecodeImageBytesPassthroughWithoutRule(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
data := []byte("raw-image-bytes")
|
||||
out, err := svc.DecodeImageBytes(t.Context(), book, srv.URL+"/a.png", false, data)
|
||||
if err != nil {
|
||||
t.Fatalf("直通路径不应报错: %v", err)
|
||||
}
|
||||
if string(out) != string(data) {
|
||||
t.Fatalf("无规则时应原样返回: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 解密脚本抛错时退回原始字节(保证至少还能看到图)。
|
||||
func TestDecodeImageBytesFallsBackOnBadRule(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
raw := strings.Replace(
|
||||
cacheTestSourceJSON(srv.URL),
|
||||
`"ruleContent": {"content": "id.content@textNodes"}`,
|
||||
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "throw new Error('bad rule')"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
data := []byte("still-an-image")
|
||||
out, err := svc.DecodeImageBytes(ctx, book, srv.URL+"/a.png", false, data)
|
||||
if err != nil {
|
||||
t.Fatalf("坏规则应降级而不是报错: %v", err)
|
||||
}
|
||||
if string(out) != string(data) {
|
||||
t.Fatalf("坏规则应返回原始字节: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 封面解密:按书源 URL(搜索结果尚未入库)执行 coverDecodeJs。
|
||||
func TestDecodeCoverBytes(t *testing.T) {
|
||||
svc, srv, _ := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
raw := strings.Replace(
|
||||
cacheTestSourceJSON(srv.URL),
|
||||
`"bookSourceType": 0,`,
|
||||
`"bookSourceType": 0, "coverDecodeJs": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x33; } out",`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if !svc.SourceHasCoverDecode(srv.URL) {
|
||||
t.Fatal("应识别出该源声明了封面解密")
|
||||
}
|
||||
plain := []byte("cover-bytes")
|
||||
encoded := make([]byte, len(plain))
|
||||
for i, b := range plain {
|
||||
encoded[i] = b ^ 0x33
|
||||
}
|
||||
out := svc.DecodeCoverBytes(ctx, srv.URL, srv.URL+"/cover.jpg", encoded)
|
||||
if string(out) != string(plain) {
|
||||
t.Fatalf("封面解密结果 = %q,期望 %q", out, plain)
|
||||
}
|
||||
|
||||
// 未声明解密的源:封面地址保持原样(不签代理)。
|
||||
plainURL := svc.RewriteBookCover(ctx, "", srv.URL+"/x", "https://img.example.com/c.jpg")
|
||||
if !strings.HasPrefix(plainURL, "https://img.example.com/") {
|
||||
t.Fatalf("无解密规则时不应改写封面: %q", plainURL)
|
||||
}
|
||||
// 声明了解密的源:封面走签名代理。
|
||||
signed := svc.RewriteBookCover(ctx, "", srv.URL, "https://img.example.com/c.jpg")
|
||||
if !strings.Contains(signed, "/api/reader/media?") || !strings.Contains(signed, "d=cover") {
|
||||
t.Fatalf("封面应走解密代理: %q", signed)
|
||||
}
|
||||
}
|
||||
|
||||
// HLS 播放列表嗅探:#EXTM3U 标记优先于 Content-Type。
|
||||
func TestReaderBodyIsPlaylist(t *testing.T) {
|
||||
playlist := []byte("#EXTM3U\n#EXT-X-VERSION:3\n")
|
||||
if !BodyIsPlaylist(playlist, "application/octet-stream") {
|
||||
t.Fatal("以 #EXTM3U 开头应判为播放列表")
|
||||
}
|
||||
if BodyIsPlaylist([]byte("\x89PNG\r\n"), "image/png") {
|
||||
t.Fatal("二进制图片不应判为播放列表")
|
||||
}
|
||||
if !BodyIsPlaylist([]byte("\n#EXTM3U\n"), "application/vnd.apple.mpegurl") {
|
||||
t.Fatal("带前置空行且 Content-Type 为 mpegurl 时应判为播放列表")
|
||||
}
|
||||
if BodyIsPlaylist(nil, "application/vnd.apple.mpegurl") {
|
||||
t.Fatal("空体不应判为播放列表")
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,11 @@ func parseLocalBookPayload(name string, data []byte) (*localBookPayload, error)
|
||||
return nil, fmt.Errorf("文件内容为空或无法解码")
|
||||
}
|
||||
out.ext, out.charset = ".txt", charset
|
||||
// 章节区间是按「解码后的 UTF-8 文本」算的字节下标,托管副本就必须落盘这份
|
||||
// UTF-8 文本:原始字节若是 GBK/Big5/UTF-16,按 UTF-8 区间去读会整章错位。
|
||||
// 外部原地引用不能改写源文件(见 importLocalBook 的 externalPath 分支),
|
||||
// 由读取侧按 charset 整文件解码后再切片。
|
||||
out.payload = []byte(decoded)
|
||||
for i, c := range splitTXTChapters(decoded) {
|
||||
out.chaps = append(out.chaps, model.ReaderChapter{
|
||||
Index: i, Title: c.Title, Tag: fmt.Sprintf("%d:%d", c.Start, c.End),
|
||||
@@ -275,11 +280,21 @@ func (s *ReaderService) ImportLocalBook(
|
||||
ext: parsed.ext,
|
||||
charset: parsed.charset,
|
||||
chaps: parsed.chaps,
|
||||
payload: data,
|
||||
payload: managedPayload(parsed, data),
|
||||
coverEntry: parsed.coverEntry,
|
||||
})
|
||||
}
|
||||
|
||||
// managedPayload 托管副本实际落盘的字节。
|
||||
//
|
||||
// TXT 走 out.payload(已解码的 UTF-8,与章节下标的基准一致);EPUB 原样落盘。
|
||||
func managedPayload(parsed *localBookPayload, raw []byte) []byte {
|
||||
if parsed != nil && len(parsed.payload) > 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 移出书架时删除本地文件(网络书籍无文件,直接返回)。
|
||||
//
|
||||
// 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,463 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件实现阅读正文的持久缓存(对应 legado BookHelp 的章节正文缓存 + CacheBook)。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后,
|
||||
// 用户替换规则、签名代理改写之前)。因此同一本书多用户共享同一份缓存,
|
||||
// 用户替换规则在读出后逐请求应用,规则改动即时生效。
|
||||
// - 索引落库(ReaderContentCache),内容落盘(cache_dir/reader-content/<origin>/<bookKey>/<key>.json)。
|
||||
// - 缓存键只包含书源身份、章节身份、内容类型、书源指纹与格式版本:不含书籍行 ID
|
||||
// (目录刷新会重建行)、不含用户(用户维度在读出后处理)。
|
||||
|
||||
// readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记)
|
||||
// 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。
|
||||
const readerContentFormatVersion = 1
|
||||
|
||||
// readerContentMaxEntryBytes 单条缓存的字节上限(超过不缓存,避免超大章节占满盘)。
|
||||
const readerContentMaxEntryBytes = 16 << 20
|
||||
|
||||
// audioCacheTTL 音频清单的短 TTL:CDN 直链常带签名/过期参数,缓存太久会拿到失效地址。
|
||||
const audioCacheTTL = 30 * time.Minute
|
||||
|
||||
// cachedChapterContent 落盘的载荷。
|
||||
type cachedChapterContent struct {
|
||||
FormatVersion int `json:"v"`
|
||||
SourceHash string `json:"src"`
|
||||
ContentType string `json:"type"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tracks []string `json:"tracks,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ImageStyle string `json:"image_style,omitempty"`
|
||||
IsHLS bool `json:"hls,omitempty"`
|
||||
Comments []ContentComment `json:"comments,omitempty"`
|
||||
SavedAt int64 `json:"saved_at"`
|
||||
}
|
||||
|
||||
// contentFlight 一次进行中的正文抓取,并发的调用方共享结果(读穿透单飞)。
|
||||
type contentFlight struct {
|
||||
done chan struct{}
|
||||
data *ChapterContent
|
||||
err error
|
||||
}
|
||||
|
||||
// ─── 键与身份 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// contentBookKey 书源身份哈希:origin + 书本地址。
|
||||
// 用「文件地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞在一起。
|
||||
func contentBookKey(origin, bookURL string) string {
|
||||
sum := sha256.Sum256([]byte(origin + "\x00" + strings.TrimSpace(bookURL)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// contentChapterIdentity 章节身份原文:绝对化 URL 优先,卷/空地址退化为标题。
|
||||
func contentChapterIdentity(book *model.ReaderBook, ch model.ReaderChapter) string {
|
||||
if ch.URL != "" && !ch.IsVolume {
|
||||
base := book.BookURL
|
||||
if strings.TrimSpace(book.TocURL) != "" {
|
||||
base = book.TocURL
|
||||
}
|
||||
if abs := rule.GetAbsoluteURL(base, ch.URL); abs != "" {
|
||||
return "url|" + abs
|
||||
}
|
||||
return "url|" + strings.TrimSpace(ch.URL)
|
||||
}
|
||||
return "title|" + strings.TrimSpace(ch.Title)
|
||||
}
|
||||
|
||||
// contentChapterKey 章节身份哈希。
|
||||
func contentChapterKey(book *model.ReaderBook, ch model.ReaderChapter) string {
|
||||
sum := sha256.Sum256([]byte(contentChapterIdentity(book, ch)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// contentSourceHash 书源内容指纹:RawJSON 哈希(书源更新后旧缓存自然失效)。
|
||||
func contentSourceHash(src *model.ReaderBookSource) string {
|
||||
if src == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(src.RawJSON))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// ─── 磁盘布局 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// readerContentDir 正文缓存根目录。
|
||||
func (s *ReaderService) readerContentDir() string {
|
||||
if s == nil || s.cfg == nil {
|
||||
return ""
|
||||
}
|
||||
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
|
||||
if base == "" {
|
||||
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
|
||||
base = filepath.Join(dataDir, "cache")
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, "reader-content")
|
||||
}
|
||||
|
||||
// contentFilePath 单条缓存的磁盘路径:<root>/<originHash>/<bookKey>/<chapterKey>.json。
|
||||
// chapterKey 已是 hex 哈希,不含路径分隔符。
|
||||
func (s *ReaderService) contentFilePath(origin, bookURL, chapterKey string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
originHash := contentBookKey(origin, "")
|
||||
return filepath.Join(root, originHash, contentBookKey(origin, bookURL), chapterKey+".json")
|
||||
}
|
||||
|
||||
// contentBookDir 某本书的缓存目录(整本清理用)。
|
||||
func (s *ReaderService) contentBookDir(origin, bookURL string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, contentBookKey(origin, ""), contentBookKey(origin, bookURL))
|
||||
}
|
||||
|
||||
// writeContentFile 原子写入缓存文件(临时文件 + rename)。
|
||||
func writeContentFile(path string, payload []byte) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("缓存目录未配置")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".content-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(name, path); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeContentFile 删除缓存文件(不存在视为成功)。
|
||||
func removeContentFile(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
// ─── 读写 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// loadCachedContent 读取一章节的缓存:命中返回内容与 true。
|
||||
//
|
||||
// contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时
|
||||
// 用于带类型校验的探测(如音频 TTL 的测试与诊断)。
|
||||
//
|
||||
// 校验链:索引存在 → 格式版本一致 → 书源指纹一致 → 未过期 → 载荷可解析。
|
||||
func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, book *model.ReaderBook, ch model.ReaderChapter, contentType string) (*ChapterContent, bool) {
|
||||
if s == nil || s.repo == nil || book == nil {
|
||||
return nil, false
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
chapterKey := contentChapterKey(book, ch)
|
||||
row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType)
|
||||
if err != nil || row == nil {
|
||||
return nil, false
|
||||
}
|
||||
if row.FormatVersion != readerContentFormatVersion {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
if row.SourceHash != "" && row.SourceHash != contentSourceHash(src) {
|
||||
// 书源更新过:旧正文可能已失效,删掉重抓。
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if row.ExpiresAt > 0 && row.ExpiresAt < now {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, chapterKey)
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
var payload cachedChapterContent
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || payload.FormatVersion != readerContentFormatVersion {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
// 命中:刷新 LRU 时间与计数(异步失败不影响读取)。
|
||||
_ = s.repo.TouchContentCache(ctx, row.ID, row.Hits+1, now)
|
||||
|
||||
out := &ChapterContent{
|
||||
Type: payload.ContentType,
|
||||
Content: payload.Content,
|
||||
Tracks: payload.Tracks,
|
||||
Images: payload.Images,
|
||||
ImageStyle: payload.ImageStyle,
|
||||
IsHLS: payload.IsHLS,
|
||||
Comments: payload.Comments,
|
||||
declaredType: -1,
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// saveCachedContent 写入一章节的缓存(空内容不写,与报错语义保持一致)。
|
||||
func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.ReaderBookSource, book *model.ReaderBook, ch model.ReaderChapter, out *ChapterContent) {
|
||||
if s == nil || s.repo == nil || book == nil || out == nil || chapterContentEmpty(out) {
|
||||
return
|
||||
}
|
||||
payload := cachedChapterContent{
|
||||
FormatVersion: readerContentFormatVersion,
|
||||
SourceHash: contentSourceHash(src),
|
||||
ContentType: out.Type,
|
||||
Content: out.Content,
|
||||
Tracks: out.Tracks,
|
||||
Images: out.Images,
|
||||
ImageStyle: out.ImageStyle,
|
||||
IsHLS: out.IsHLS,
|
||||
Comments: out.Comments,
|
||||
SavedAt: time.Now().Unix(),
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil || len(raw) > readerContentMaxEntryBytes {
|
||||
return
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, ch))
|
||||
if err := writeContentFile(path, raw); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("reader: 写入正文缓存失败", zap.String("path", path), zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
row := &model.ReaderContentCache{
|
||||
OriginHash: contentBookKey(book.Origin, ""),
|
||||
BookKey: contentBookKey(book.Origin, book.BookURL),
|
||||
ChapterKey: contentChapterKey(book, ch),
|
||||
ChapterIdentity: contentChapterIdentity(book, ch),
|
||||
ChapterIndex: ch.Index,
|
||||
ContentType: out.Type,
|
||||
SourceHash: payload.SourceHash,
|
||||
FormatVersion: readerContentFormatVersion,
|
||||
SizeBytes: int64(len(raw)),
|
||||
AssetCount: len(out.Tracks) + len(out.Images),
|
||||
ExpiresAt: s.contentExpiry(out.Type, now),
|
||||
LastAccessAt: now,
|
||||
}
|
||||
if err := s.repo.UpsertContentCache(ctx, row); err != nil && s.log != nil {
|
||||
s.log.Warn("reader: 写入正文缓存索引失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// contentExpiry 按内容类型给出过期时间:音频清单短 TTL(直链会过期),文本/图片用配置的 TTL。
|
||||
func (s *ReaderService) contentExpiry(contentType string, now int64) int64 {
|
||||
if contentType == "audio" {
|
||||
return now + int64(audioCacheTTL.Seconds())
|
||||
}
|
||||
ttlHours := 0
|
||||
if s != nil && s.cfg != nil {
|
||||
ttlHours = s.cfg.Cache.ReaderContentTTLHours
|
||||
}
|
||||
if ttlHours <= 0 {
|
||||
return 0
|
||||
}
|
||||
return now + int64(ttlHours)*3600
|
||||
}
|
||||
|
||||
// dropContentCacheRow 删除索引并清理磁盘文件。
|
||||
func (s *ReaderService) dropContentCacheRow(ctx context.Context, row *model.ReaderContentCache) {
|
||||
if s == nil || s.repo == nil || row == nil {
|
||||
return
|
||||
}
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
path := s.contentPathFromRow(row)
|
||||
removeContentFile(path)
|
||||
if path != "" {
|
||||
s.pruneContentDirIfEmpty(filepath.Dir(path))
|
||||
}
|
||||
}
|
||||
|
||||
// pruneContentDirIfEmpty 删掉空目录(缓存清理后不留空壳)。
|
||||
func (s *ReaderService) pruneContentDirIfEmpty(dir string) {
|
||||
if dir == "" || dir == s.readerContentDir() {
|
||||
return
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
|
||||
_ = os.Remove(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearContentCacheForBook 清理一本书的全部正文缓存(换源/移出书架时调用)。
|
||||
// 同源同书可能被多个用户收藏:仍被引用时不删(见 DeleteBook/ClearBookOrigin)。
|
||||
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, origin, bookURL string) {
|
||||
if s == nil || s.repo == nil || origin == "" || bookURL == "" {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(origin, bookURL)
|
||||
if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil {
|
||||
return
|
||||
}
|
||||
if dir := s.contentBookDir(origin, bookURL); dir != "" {
|
||||
_ = os.RemoveAll(dir)
|
||||
s.pruneContentDirIfEmpty(filepath.Dir(dir))
|
||||
}
|
||||
}
|
||||
|
||||
// PruneContentCache 清理阅读正文缓存(TTL 过期 + 容量 LRU),供调度器按小时调用。
|
||||
func (s *ReaderService) PruneContentCache(ctx context.Context) {
|
||||
s.pruneReaderContentCache(ctx)
|
||||
}
|
||||
|
||||
// pruneReaderContentCache 容量/TTL 淘汰:过期条目直接删,超配额按 LRU 删到 90%。
|
||||
func (s *ReaderService) pruneReaderContentCache(ctx context.Context) {
|
||||
if s == nil || s.repo == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
if expired, err := s.repo.ListContentCacheExpired(ctx, now, 500); err == nil {
|
||||
for i := range expired {
|
||||
row := expired[i]
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
removeContentFile(s.contentPathFromRow(&row))
|
||||
}
|
||||
}
|
||||
maxMB := 0
|
||||
if s.cfg != nil {
|
||||
maxMB = s.cfg.Cache.ReaderContentMaxSizeMB
|
||||
}
|
||||
if maxMB <= 0 {
|
||||
return
|
||||
}
|
||||
_, totalBytes, err := s.repo.ContentCacheStats(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
limit := int64(maxMB) << 20
|
||||
if totalBytes <= limit {
|
||||
return
|
||||
}
|
||||
target := limit * 9 / 10
|
||||
oldest, err := s.repo.ListContentCacheOldest(ctx, 500)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := range oldest {
|
||||
if totalBytes <= target {
|
||||
break
|
||||
}
|
||||
row := oldest[i]
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
removeContentFile(s.contentPathFromRow(&row))
|
||||
totalBytes -= row.SizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
// contentPathFromRow 由索引行拼磁盘路径(索引存了 origin_hash / book_key / chapter_key)。
|
||||
func (s *ReaderService) contentPathFromRow(row *model.ReaderContentCache) string {
|
||||
if s == nil || row == nil {
|
||||
return ""
|
||||
}
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, row.OriginHash, row.BookKey, row.ChapterKey+".json")
|
||||
}
|
||||
|
||||
// ─── 目录刷新后的缓存迁移(对应 legado BookHelp.remapContentCache) ─────────
|
||||
|
||||
// RemapContentCacheOnTocChange 目录刷新后迁移缓存键:同一章按绝对化 URL 命中、
|
||||
// 标题唯一命中兜底。不迁移的话每次「更新目录」都会让已缓存正文全部冷启动。
|
||||
func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *model.ReaderBook, oldChapters, newChapters []model.ReaderChapter) {
|
||||
if s == nil || s.repo == nil || book == nil || len(oldChapters) == 0 || len(newChapters) == 0 {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
// 旧身份 → 新章节。
|
||||
byURL := map[string]model.ReaderChapter{}
|
||||
byTitle := map[string][]model.ReaderChapter{}
|
||||
for _, ch := range newChapters {
|
||||
if ch.URL != "" && !ch.IsVolume {
|
||||
base := book.BookURL
|
||||
if strings.TrimSpace(book.TocURL) != "" {
|
||||
base = book.TocURL
|
||||
}
|
||||
abs := rule.GetAbsoluteURL(base, ch.URL)
|
||||
if abs == "" {
|
||||
abs = strings.TrimSpace(ch.URL)
|
||||
}
|
||||
byURL["url|"+abs] = ch
|
||||
}
|
||||
if t := strings.TrimSpace(ch.Title); t != "" {
|
||||
byTitle["title|"+t] = append(byTitle["title|"+t], ch)
|
||||
}
|
||||
}
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
var target model.ReaderChapter
|
||||
found := false
|
||||
if ch, ok := byURL[row.ChapterIdentity]; ok {
|
||||
target, found = ch, true
|
||||
} else if matches := byTitle[row.ChapterIdentity]; len(matches) == 1 {
|
||||
// 标题唯一命中才迁移:多个同标题章节时无法确定是哪一章,宁可重抓。
|
||||
target, found = matches[0], true
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
newKey := contentChapterKey(book, target)
|
||||
if newKey == row.ChapterKey {
|
||||
continue // 身份未变,只更新序号
|
||||
}
|
||||
oldPath := s.contentPathFromRow(&row)
|
||||
newPath := s.contentFilePath(book.Origin, book.BookURL, newKey)
|
||||
if oldPath != "" && newPath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil {
|
||||
// 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。
|
||||
_ = os.Rename(oldPath, newPath)
|
||||
}
|
||||
}
|
||||
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
|
||||
row.ID = ""
|
||||
row.ChapterKey = newKey
|
||||
row.ChapterIdentity = contentChapterIdentity(book, target)
|
||||
row.ChapterIndex = target.Index
|
||||
row.LastAccessAt = time.Now().Unix()
|
||||
_ = s.repo.UpsertContentCache(ctx, &row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 正文持久缓存(reader_content_cache.go)的端到端测试:
|
||||
// 抓取 → 写缓存 → 再次读取命中且不再访问书源 → 批量接口统计正确。
|
||||
|
||||
// cacheTestServer 每章返回固定正文并统计各章请求次数。
|
||||
type cacheTestServer struct {
|
||||
*httptest.Server
|
||||
mu sync.Mutex
|
||||
hits map[string]int
|
||||
body func(chapter int) string
|
||||
failOn map[int]bool
|
||||
}
|
||||
|
||||
func newCacheTestServer() *cacheTestServer {
|
||||
s := &cacheTestServer{hits: map[string]int{}, failOn: map[int]bool{}}
|
||||
s.body = func(chapter int) string { return fmt.Sprintf("第%d章的正文内容。", chapter) }
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
|
||||
_, _ = w.Write([]byte(cacheTestTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/1/c"):
|
||||
chapter := 0
|
||||
_, _ = fmt.Sscanf(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/book/1/c"), ".html"), "%d", &chapter)
|
||||
s.mu.Lock()
|
||||
s.hits[fmt.Sprintf("c%d", chapter)]++
|
||||
fail := s.failOn[chapter]
|
||||
s.mu.Unlock()
|
||||
if fail {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(fmt.Sprintf(`<html><body><div id="content">%s</div></body></html>`, s.body(chapter))))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(cacheTestBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *cacheTestServer) count(chapter int) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hits[fmt.Sprintf("c%d", chapter)]
|
||||
}
|
||||
|
||||
func cacheTestSourceJSON(server string) string {
|
||||
return fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "缓存测试源",
|
||||
"bookSourceType": 0,
|
||||
"searchUrl": "%s/search/{{key}}/1.html",
|
||||
"ruleSearch": {"bookList": "class.item", "name": "tag.h3@text", "bookUrl": "tag.a@href"},
|
||||
"ruleBookInfo": {"name": "class.info@tag.h1@text", "tocUrl": "class.info@class.toc@href"},
|
||||
"ruleToc": {"chapterList": "class.chapters@tag.li", "chapterName": "tag.a@text", "chapterUrl": "tag.a@href"},
|
||||
"ruleContent": {"content": "id.content@textNodes"}
|
||||
}`, server, server)
|
||||
}
|
||||
|
||||
const cacheTestTocHTML = `<html><body>
|
||||
<ul class="chapters">
|
||||
<li><a href="/book/1/c1.html">第 1 章</a></li>
|
||||
<li><a href="/book/1/c2.html">第 2 章</a></li>
|
||||
<li><a href="/book/1/c3.html">第 3 章</a></li>
|
||||
</ul></body></html>`
|
||||
|
||||
const cacheTestBookInfoHTML = `<html><body>
|
||||
<div class="info"><h1>缓存之书</h1><a class="toc" href="/book/1/toc">目录</a></div>
|
||||
</body></html>`
|
||||
|
||||
// prepareCacheTestBook 建好「书源 + 书架书籍 + 目录缓存」并打开独立缓存目录。
|
||||
func prepareCacheTestBook(t *testing.T) (*ReaderService, *cacheTestServer, *model.ReaderBook) {
|
||||
t.Helper()
|
||||
srv := newCacheTestServer()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
svc.cfg.Cache.ReaderContentTTLHours = 168
|
||||
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
ctx := t.Context()
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "缓存之书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
chapters := make([]ChapterInput, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
chapters = append(chapters, ChapterInput{
|
||||
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
|
||||
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
|
||||
})
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
return svc, srv, book
|
||||
}
|
||||
|
||||
// 第一次读取抓源并写缓存;第二次直接命中持久缓存,不再访问书源。
|
||||
func TestContentCacheHitAndPersist(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(first.Content, "第1章的正文内容。") {
|
||||
t.Fatalf("正文不符合预期: %q", first.Content)
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("首次读取应访问书源 1 次,实际 %d", got)
|
||||
}
|
||||
|
||||
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("二次读取失败: %v", err)
|
||||
}
|
||||
if second.Content != first.Content {
|
||||
t.Fatalf("缓存命中的正文不一致: %q vs %q", second.Content, first.Content)
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got)
|
||||
}
|
||||
|
||||
// 缓存文件确实落盘
|
||||
path := svc.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, model.ReaderChapter{
|
||||
Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html",
|
||||
}))
|
||||
if path == "" {
|
||||
t.Fatal("缓存路径为空(cache_dir 未生效)")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("缓存文件未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 目录刷新后同一章(地址不变)仍应命中缓存,不因「更新目录」冷启动。
|
||||
func TestContentCacheSurvivesTocRefresh(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("读取第 2 章失败: %v", err)
|
||||
}
|
||||
|
||||
// 模拟「更新目录」:同一批章节重新落库(行 ID 会变,身份不变)。
|
||||
chapters := make([]ChapterInput, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
chapters = append(chapters, ChapterInput{
|
||||
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
|
||||
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
|
||||
})
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
|
||||
t.Fatalf("重写目录失败: %v", err)
|
||||
}
|
||||
// 刷新后的 remap(refreshBookToc 内的同一调用)。
|
||||
oldChapters := []model.ReaderChapter{
|
||||
{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"},
|
||||
{Index: 1, Title: "第 2 章", URL: srv.URL + "/book/1/c2.html"},
|
||||
{Index: 2, Title: "第 3 章", URL: srv.URL + "/book/1/c3.html"},
|
||||
}
|
||||
newChapters, _ := svc.repo.ListChapters(ctx, book.ID)
|
||||
svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters)
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("刷新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 1 {
|
||||
t.Fatalf("目录刷新后应命中缓存(书源请求仍为 1 次),实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发请求同一章只应打一次书源(读穿透单飞)。
|
||||
func TestContentCacheSingleFlight(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 6)
|
||||
for i := 0; i < 6; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("并发读取 %d 失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := srv.count(1); got != 1 {
|
||||
t.Fatalf("同一章并发应单飞为 1 次书源请求,实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量接口:单章失败不影响其余章节,统计 hit/miss/failed。
|
||||
func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 预热第 1 章(进缓存),第 2 章按失败处理。
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("预热失败: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = true
|
||||
srv.mu.Unlock()
|
||||
|
||||
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("批量读取失败: %v", err)
|
||||
}
|
||||
if len(result.Items) != 3 {
|
||||
t.Fatalf("应返回 3 项,实际 %d", len(result.Items))
|
||||
}
|
||||
if result.Stats.Hit != 1 {
|
||||
t.Fatalf("第 1 章应命中缓存,hit=%d", result.Stats.Hit)
|
||||
}
|
||||
if result.Stats.Failed != 1 {
|
||||
t.Fatalf("第 2 章应失败,failed=%d", result.Stats.Failed)
|
||||
}
|
||||
if result.Stats.Miss != 1 {
|
||||
t.Fatalf("第 3 章应为新抓,miss=%d", result.Stats.Miss)
|
||||
}
|
||||
if !strings.Contains(result.Items[2].Content, "第3章的正文内容。") {
|
||||
t.Fatalf("第 3 章内容异常: %+v", result.Items[2])
|
||||
}
|
||||
if result.Items[1].Error == "" {
|
||||
t.Fatalf("第 2 章应带错误: %+v", result.Items[1])
|
||||
}
|
||||
// 失败章不写缓存:去掉失败开关后重试应真正抓源。
|
||||
srv.mu.Lock()
|
||||
srv.failOn[2] = false
|
||||
srv.mu.Unlock()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
|
||||
t.Fatalf("恢复后读取第 2 章失败: %v", err)
|
||||
}
|
||||
if got := srv.count(2); got != 2 {
|
||||
t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got)
|
||||
}
|
||||
// 越界/卷章节被忽略。
|
||||
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 {
|
||||
t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 书源更新(RawJSON 变化)后旧缓存失效,重新抓取。
|
||||
func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("首次读取失败: %v", err)
|
||||
}
|
||||
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
|
||||
updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1)
|
||||
if _, err := svc.ImportSources(ctx, updated); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("更新后读取失败: %v", err)
|
||||
}
|
||||
if got := srv.count(1); got != 2 {
|
||||
t.Fatalf("书源更新后应重抓(2 次),实际 %d 次", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 移出书架且无其他引用时清理缓存文件与索引。
|
||||
func TestContentCacheClearedOnRemoveBook(t *testing.T) {
|
||||
svc, _, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
dir := svc.contentBookDir(book.Origin, book.BookURL)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("缓存目录应存在: %v", err)
|
||||
}
|
||||
if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatalf("移出书架后缓存目录应被清理,err=%v", err)
|
||||
}
|
||||
if rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(book.Origin, book.BookURL)); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("缓存索引应清空: rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
// 音频清单使用短 TTL:过期后不命中并自动清理。
|
||||
func TestContentCacheAudioShortTTL(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
|
||||
ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}
|
||||
svc.saveCachedContent(ctx, mustSource(t, svc, srv.URL), book, ch, &ChapterContent{
|
||||
Type: "audio", Tracks: []string{srv.URL + "/a.mp3"},
|
||||
})
|
||||
row, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio")
|
||||
if err != nil {
|
||||
t.Fatalf("音频缓存未写入: %v", err)
|
||||
}
|
||||
if row.ExpiresAt == 0 {
|
||||
t.Fatal("音频缓存应带 TTL")
|
||||
}
|
||||
// 过期后读取视为未命中,并清掉条目。
|
||||
row.ExpiresAt = 1
|
||||
if err := svc.repo.UpsertContentCache(ctx, row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := svc.loadCachedContent(ctx, mustSource(t, svc, srv.URL), nil, book, ch, "audio"); ok {
|
||||
t.Fatal("过期缓存不应命中")
|
||||
}
|
||||
if _, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio"); err == nil {
|
||||
t.Fatal("过期条目应被删除")
|
||||
}
|
||||
}
|
||||
|
||||
// mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。
|
||||
func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource {
|
||||
t.Helper()
|
||||
src, err := svc.repo.GetSourceByURL(context.Background(), sourceURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取书源失败: %v", err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// 缓存键:不同书源/书本地址/章节地址必须落在不同键上。
|
||||
func TestContentCacheKeys(t *testing.T) {
|
||||
base := &model.ReaderBook{Origin: "https://a.com", BookURL: "https://a.com/book/1", TocURL: "https://a.com/toc/1"}
|
||||
ch1 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "/book/1/c1.html"}
|
||||
ch2 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "https://a.com/book/1/c1.html"}
|
||||
// 相对与绝对地址指向同一章:绝对化后身份一致。
|
||||
if contentChapterKey(base, ch1) != contentChapterKey(base, ch2) {
|
||||
t.Fatal("同一章的相对/绝对地址应得到同一身份")
|
||||
}
|
||||
if contentBookKey("https://a.com", "https://a.com/book/1") == contentBookKey("https://b.com", "https://a.com/book/1") {
|
||||
t.Fatal("不同书源的同一地址不应共享键")
|
||||
}
|
||||
if contentChapterIdentity(base, model.ReaderChapter{Index: 0, Title: "卷名", IsVolume: true}) != "title|卷名" {
|
||||
t.Fatal("卷章节应退化为标题身份")
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存目录不可用时(未配置)不应影响正文读取。
|
||||
func TestContentCacheWithoutCacheDir(t *testing.T) {
|
||||
srv := newCacheTestServer()
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = ""
|
||||
svc.cfg.App.DataDir = filepath.Join(t.TempDir(), "missing", "data")
|
||||
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
|
||||
ctx := t.Context()
|
||||
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
|
||||
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "缓存之书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("无缓存目录时读取失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.Content, "第1章") {
|
||||
t.Fatalf("正文异常: %q", out.Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:拷贝系列轻小说源的端到端回归。
|
||||
//
|
||||
// 合成一个「照搬拷贝轻小说写法」的书源,一次覆盖四个修复点:
|
||||
// 1. 搜索列表规则 `results.list.[*]`(legado 的 `.[*]` 形式);
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()/ArrayList()` 拼装;
|
||||
// 3. 正文规则用 `java.cacheFile(url)` 取整卷文本;
|
||||
// 4. 正文是 GBK 文本——必须走 Raw 原始字节通道落盘,否则会被 UTF-8 解码成乱码。
|
||||
|
||||
func TestCopyLightNovelSourceEndToEnd(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
gbkBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("第一章 开始\n第二章 继续"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// 搜索:Django REST 风格的 JSON。q_type/_update 参数照抄真实源。
|
||||
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[
|
||||
{"name":"转生成为史莱姆","path_word":"slime","author":[{"name":"作者甲"}],"cover":"https://img.example.com/1.jpg"}
|
||||
]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/book/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"book":{"name":"转生成为史莱姆","path_word":"slime"}}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volumes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[{"name":"第一卷","id":7}]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volume/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"code":200,"results":{"volume":{"txt_addr":"%s/api/txt/7","txt_encoding":"GBK"}}}`, srv.URL)
|
||||
})
|
||||
mux.HandleFunc("/api/txt/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(gbkBody)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
source := map[string]any{
|
||||
"bookSourceName": "合成拷贝轻小说源",
|
||||
"bookSourceUrl": srv.URL,
|
||||
"enabled": true,
|
||||
"enabledExplore": true,
|
||||
"searchUrl": srv.URL + "/api/search?limit=20&q_type=&q={{key}}&_update=true",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "results.list.[*]",
|
||||
"name": "$.name",
|
||||
"author": "$.author[*].name",
|
||||
"coverUrl": "$.cover",
|
||||
"bookUrl": srv.URL + "/api/book/{{$.path_word}}?_update=true",
|
||||
},
|
||||
"ruleBookInfo": map[string]any{
|
||||
"init": "$.results.book",
|
||||
"name": "$.name",
|
||||
"intro": "$.brief",
|
||||
"tocUrl": srv.URL + "/api/volumes?book={{$.path_word}}&_update=true",
|
||||
},
|
||||
"ruleToc": map[string]any{
|
||||
"chapterList": `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = '` + srv.URL + `/api/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id + '?_update=true')); }
|
||||
out;</js>`,
|
||||
"chapterName": "$.name",
|
||||
"chapterUrl": "$.url",
|
||||
},
|
||||
"ruleContent": map[string]any{
|
||||
"content": `@js:java.cacheFile(java.getString('$..txt_addr'))`,
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
|
||||
res := svc.SmokeSource(t.Context(), string(raw), "史莱姆")
|
||||
if !res.OK {
|
||||
t.Fatalf("链路失败于 %s: %s\n%s", res.FailedAt, res.Error, joinLogs(res))
|
||||
}
|
||||
if res.SearchHits != 1 {
|
||||
t.Fatalf("搜索命中=%d, want 1(`.[*]` 路径修复)", res.SearchHits)
|
||||
}
|
||||
if res.Chapters != 1 {
|
||||
t.Fatalf("章节数=%d, want 1(Packages 修复)", res.Chapters)
|
||||
}
|
||||
if res.ContentLen == 0 {
|
||||
t.Fatalf("正文长度=0(cacheFile/Raw 修复未生效)\n%s", joinLogs(res))
|
||||
}
|
||||
if !strings.Contains(joinLogs(res), "第一章") {
|
||||
t.Fatalf("正文未正确解码(GBK 落盘被 utf-8 破坏)\n%s", joinLogs(res))
|
||||
}
|
||||
}
|
||||
|
||||
func joinLogs(res *SmokeChainResult) string {
|
||||
var b strings.Builder
|
||||
for _, l := range res.Logs {
|
||||
fmt.Fprintf(&b, "[%s/%s] %s\n", l.Stage, l.Level, l.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -26,14 +26,20 @@ type AnalyzeRule struct {
|
||||
chapterVars map[string]string
|
||||
bookVars map[string]string
|
||||
vars map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// sourceDefaults 书源 JSON 的 variables 字段(作者设定的默认值)。
|
||||
// 它只作为 @get 的兜底:显式保存过的书源变量与会话内 @put 的值优先级更高。
|
||||
sourceDefaults map[string]string
|
||||
chapterTitle string
|
||||
chapterIndex int
|
||||
bookName string
|
||||
// bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。
|
||||
bookMeta map[string]any
|
||||
// bookCustom 书籍自定义变量(对应 legado Book.variableMap),
|
||||
// 由规则 JS 的 book.getVariable / book.putVariable 读写。
|
||||
bookCustom map[string]string
|
||||
// bookVarPutter 由服务层注册:book.putVariable 写入后触发,
|
||||
// 用于把变量变更持久化回书架记录(对应 legado 的 Book.upVariable)。
|
||||
bookVarPutter func()
|
||||
// bookTypeOverride 书源在规则 JS 里给 book.type 赋的值
|
||||
// (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。
|
||||
bookTypeOverride *int
|
||||
@@ -120,6 +126,17 @@ func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// BookCustomVars 返回书籍自定义变量的当前值(可能被 book.putVariable 改过),
|
||||
// 服务层据此把变更写回书架记录。没有书籍上下文时为 nil。
|
||||
func (a *AnalyzeRule) BookCustomVars() map[string]string {
|
||||
return a.bookCustom
|
||||
}
|
||||
|
||||
// RegisterBookVariablePutter 注册书籍变量变更回调(book.putVariable 写入后触发)。
|
||||
func (a *AnalyzeRule) RegisterBookVariablePutter(putter func()) {
|
||||
a.bookVarPutter = putter
|
||||
}
|
||||
|
||||
// SetBookType 记录书源声明的书籍类型(legado Book.type)。
|
||||
func (a *AnalyzeRule) SetBookType(t int) {
|
||||
v := t
|
||||
@@ -154,6 +171,14 @@ func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceDefaults 注入书源 variables 字段的默认值(@get 的兜底)。
|
||||
// 显式保存过的源变量与会话内 @put 的值优先级都高于它。
|
||||
func (a *AnalyzeRule) SetSourceDefaults(vars map[string]string) {
|
||||
if len(vars) > 0 {
|
||||
a.sourceDefaults = vars
|
||||
}
|
||||
}
|
||||
|
||||
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
|
||||
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
|
||||
a.sourceGetter = getter
|
||||
@@ -172,13 +197,19 @@ func jsonpathGet(path string, root any) (v any, err error) {
|
||||
|
||||
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
|
||||
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → source。
|
||||
// Put 对应 put(key, value):chapter → book → 局部 → 书源持久变量。
|
||||
//
|
||||
// 「书源持久变量」是 @put 在 legado 里的真实落点(BaseSource.putVariable):
|
||||
// 写进去的值跨请求可见。书源默认变量(variables 字段)与书籍自定义变量都
|
||||
// 不是这个通道,所以最后才回退到 sourcePutter,而不是写一次性的 bookVars。
|
||||
func (a *AnalyzeRule) Put(key, value string) string {
|
||||
switch {
|
||||
case a.chapterVars != nil:
|
||||
a.chapterVars[key] = value
|
||||
case a.bookVars != nil:
|
||||
a.bookVars[key] = value
|
||||
case a.sourcePutter != nil:
|
||||
a.sourcePutter(key, value)
|
||||
default:
|
||||
if a.vars == nil {
|
||||
a.vars = map[string]string{}
|
||||
@@ -189,6 +220,7 @@ func (a *AnalyzeRule) Put(key, value string) string {
|
||||
}
|
||||
|
||||
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
|
||||
// 查找顺序 chapter → book → book 变量之上的显式源变量 → 书源默认变量。
|
||||
func (a *AnalyzeRule) Get(key string) string {
|
||||
switch key {
|
||||
case "bookName":
|
||||
@@ -212,6 +244,9 @@ func (a *AnalyzeRule) Get(key string) string {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if v, ok := a.sourceDefaults[key]; ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
@@ -245,6 +247,12 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
// 字节参数可能是 ArrayBuffer / typed array:这类值的 Export() 返回属性 map,
|
||||
// 只有 ExportTo 才能取回字节,旧写法会退化成 String(value) 得到 "[object ArrayBuffer]"。
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(call.Arguments[0], &buf); err == nil {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
if buf, ok := call.Arguments[0].Export().([]byte); ok {
|
||||
return vm.ToValue(string(buf))
|
||||
}
|
||||
@@ -435,6 +443,95 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 本地文件(对应 legado JsExtensions 的缓存/文件系列) ──
|
||||
//
|
||||
// 服务端把文件限制在自己的缓存目录(<cache>/reader/files)内,
|
||||
// 书源只能用相对路径(允许以 / 开头,downloadFile 的返回值即此形态)。
|
||||
set("downloadFile", func(call goja.FunctionCall) goja.Value {
|
||||
// 旧签名 downloadFile(contentHex, url):把 hex 字符串落成文件。
|
||||
if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
url := stringArg(call, 1)
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
rel, err := writeCacheFile(r.cfg.CacheDir, cacheFileName(url), raw)
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
}
|
||||
rel, err := downloadToCache(r, stringArg(call, 0))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
})
|
||||
// cacheFile(url): 下载并缓存文本文件,返回文件**内容**(对应 legado 语义)。
|
||||
set("cacheFile", func(call goja.FunctionCall) goja.Value {
|
||||
url := stringArg(call, 0)
|
||||
if dir := readerFilesDir(r.cfg.CacheDir); dir != "" {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, cacheFileName(url))); err == nil && len(b) > 0 {
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
}
|
||||
}
|
||||
rel, err := downloadToCache(r, url)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, rel)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("readFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(b))
|
||||
})
|
||||
set("readTxtFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
if cs := stringArgOr(call, 1, ""); cs != "" {
|
||||
if s, derr := DecodeBytes(b, cs); derr == nil {
|
||||
return vm.ToValue(s)
|
||||
}
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("deleteFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
// getFile(path): 返回 File 对象(提供书源常用的 exists/length/name/delete/readText)。
|
||||
set("getFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
full = ""
|
||||
}
|
||||
return newFileObject(vm, full, stringArg(call, 0))
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
bindSourceState(vm, set, r.state, r.cfg.SourceProps)
|
||||
@@ -540,13 +637,18 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name, "需要本地文件/压缩包能力"))
|
||||
set(name, unsupported(name, "需要压缩包解压能力"))
|
||||
}
|
||||
// importScript 在 legado 里是「下载 JS 文件并 eval」,服务端可做但会引入
|
||||
// 任意脚本执行面,暂不支持,保持明确报错。
|
||||
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
|
||||
|
||||
// 字体混淆还原(queryTTF / queryBase64TTF / replaceFont)。
|
||||
r.installFontBridge(vm, set)
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件实现 legado JsExtensions 里的「本地文件」能力
|
||||
// (cacheFile / downloadFile / readFile / readTxtFile / deleteFile / getFile)。
|
||||
//
|
||||
// 与 legado 的语义保持一致:
|
||||
// - downloadFile(url) 下载到缓存目录,返回「相对缓存根目录」的路径;
|
||||
// - cacheFile(url) 下载(带缓存)后返回文件的**文本内容**(不是路径);
|
||||
// - readFile/readTxtFile/deleteFile/getFile 接受相对路径(允许以 / 开头),
|
||||
// 一律解析到缓存目录下。
|
||||
//
|
||||
// 服务端把缓存根目录固定为 <cache>/reader/files,并且只允许访问该目录内的文件,
|
||||
// 避免书源通过 `../../` 之类读到宿主上的任意文件。
|
||||
|
||||
// filesSubdir 书源文件缓存目录(相对 ReaderService 的缓存根目录)。
|
||||
const filesSubdir = "reader/files"
|
||||
|
||||
// readerFilesDir 返回书源文件缓存目录;未配置缓存目录时返回空串。
|
||||
func readerFilesDir(cacheDir string) string {
|
||||
if strings.TrimSpace(cacheDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheDir, "reader", "files")
|
||||
}
|
||||
|
||||
// cacheFileName 计算某个 URL 在缓存目录里的文件名(对应 legado 的
|
||||
// `${md5Encode16(url)}.${type}`:md5 十六进制取中间 16 位,保留 URL 后缀)。
|
||||
func cacheFileName(rawURL string) string {
|
||||
name := md5Hex(rawURL, true)
|
||||
if ext := urlFileSuffix(rawURL); ext != "" {
|
||||
name += "." + ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// urlFileSuffix 取 URL 路径部分的后缀(不含 `.`),只接受字母数字(长度 1~8)。
|
||||
func urlFileSuffix(rawURL string) string {
|
||||
u := rawURL
|
||||
if i := strings.IndexAny(u, "?#"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if i := strings.LastIndex(u, "/"); i >= 0 {
|
||||
u = u[i+1:]
|
||||
}
|
||||
dot := strings.LastIndex(u, ".")
|
||||
if dot < 0 || dot == len(u)-1 {
|
||||
return ""
|
||||
}
|
||||
ext := u[dot+1:]
|
||||
if len(ext) > 8 {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(ext); i++ {
|
||||
c := ext[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// resolveCacheFilePath 把书源给的相对路径解析成缓存目录下的绝对路径。
|
||||
// 允许以 `/` 开头(downloadFile 的返回值就是这种形态),但拒绝越出缓存目录。
|
||||
func resolveCacheFilePath(cacheDir, p string) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("文件路径为空")
|
||||
}
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.Trim(p, `/\`)))
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("非法文件路径: %s", p)
|
||||
}
|
||||
return filepath.Join(dir, rel), nil
|
||||
}
|
||||
|
||||
// writeCacheFile 把字节写入缓存目录,返回相对路径(以 / 开头,对应 legado)。
|
||||
func writeCacheFile(cacheDir, name string, data []byte) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/" + name, nil
|
||||
}
|
||||
|
||||
// downloadToCache 下载 URL 并落到缓存目录(已存在则直接复用),
|
||||
// 返回相对缓存根目录的路径。下载走书源会话,因此会带上书源请求头与 Cookie。
|
||||
func downloadToCache(r *JSRunner, rawURL string) (string, error) {
|
||||
dir := readerFilesDir(r.cfg.CacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
name := cacheFileName(rawURL)
|
||||
full := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(full); err == nil && st.Size() > 0 {
|
||||
return "/" + name, nil
|
||||
}
|
||||
req, err := ParseAnalyzeUrlWithJS(rawURL, "", 0, r.cfg.BaseURL, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return "", req.Unsupported
|
||||
}
|
||||
// Raw:响应按原始字节返回,避免 charset 解码破坏二进制/非 UTF-8 文本。
|
||||
// 同时清掉 HexBody:URL 选项里的 `type` 在 legado 是文件后缀提示,
|
||||
// 这里不该把下载内容按 hex 字符串落盘。
|
||||
req.Raw = true
|
||||
req.HexBody = false
|
||||
body, _, code, err := r.fetch(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if code >= 400 {
|
||||
return "", fmt.Errorf("下载失败 HTTP %d: %s", code, rawURL)
|
||||
}
|
||||
return writeCacheFile(r.cfg.CacheDir, name, []byte(body))
|
||||
}
|
||||
|
||||
// decodeTextAuto 解码文本文件:UTF-8 BOM → 去 BOM;合法 UTF-8 → 原样;
|
||||
// 否则按 GBK 解(中文站点/书源最常见的另一种编码),失败再原样返回。
|
||||
func decodeTextAuto(b []byte) string {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return string(b[3:])
|
||||
}
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
if s, err := DecodeBytes(b, "gbk"); err == nil && s != "" {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// fileSuffixFromName 取路径的最后一段(getFile 的 name 语义)。
|
||||
func fileSuffixFromName(p string) string {
|
||||
return path.Base(strings.ReplaceAll(p, "\\", "/"))
|
||||
}
|
||||
|
||||
// newFileObject 构造 `java.getFile(path)` 返回的 File 等价物,
|
||||
// 提供书源常用的 exists / length / name / delete / readText。
|
||||
func newFileObject(vm *goja.Runtime, full, raw string) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) { _ = o.Set(k, v) }
|
||||
set("path", full)
|
||||
set("name", fileSuffixFromName(raw))
|
||||
set("exists", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
_, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil)
|
||||
})
|
||||
set("isDirectory", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil && st.IsDir())
|
||||
})
|
||||
set("length", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
return vm.ToValue(st.Size())
|
||||
})
|
||||
set("delete", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
set("readText", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("toString", func(goja.FunctionCall) goja.Value { return vm.ToValue(full) })
|
||||
return o
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:java 文件接口(cacheFile / downloadFile / readTxtFile / deleteFile)
|
||||
// 的语义回归。对应 legado JsExtensions:
|
||||
// - cacheFile(url) → 返回文件**文本内容**(不是路径);
|
||||
// - downloadFile(url) → 返回相对缓存根目录的路径;
|
||||
// - readTxtFile(path[, charset]) → 返回文本;
|
||||
// - 只允许访问缓存目录内的文件。
|
||||
|
||||
func newFileTestRunner(cacheDir, baseURL string, hits *int32) *JSRunner {
|
||||
return NewJSRunner(JSConfig{
|
||||
CacheDir: cacheDir,
|
||||
BaseURL: baseURL,
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if hits != nil {
|
||||
atomic.AddInt32(hits, 1)
|
||||
}
|
||||
resp, err := http.Get(req.URL) //nolint:gosec // 测试内固定 httptest 地址
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b), req.URL, resp.StatusCode, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheFileReturnsTextContent(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("第一章 开始\n第二章 继续"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, &hits)
|
||||
ar := NewAnalyzeRule()
|
||||
v, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cacheFile 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第一章 开始\n第二章 继续" {
|
||||
t.Fatalf("cacheFile 应返回文本内容,实际 %q", got)
|
||||
}
|
||||
// 第二次应命中缓存,不再发起请求。
|
||||
if _, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||||
t.Fatalf("重复调用应命中缓存(请求次数=%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheFileDecodesGBK(t *testing.T) {
|
||||
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("中文内容测试"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(gbk)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, nil)
|
||||
v, err := r.Run(NewAnalyzeRule(), "java.cacheFile('"+srv.URL+"/gbk.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "中文内容测试" {
|
||||
t.Fatalf("GBK 文本未正确解码: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileThenReadTxtFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("正文内容"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
r := newFileTestRunner(cacheDir, srv.URL, nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
rawPath, err := r.Run(ar, "java.downloadFile('"+srv.URL+"/a.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := anyToString(rawPath)
|
||||
if !strings.HasPrefix(rel, "/") || !strings.HasSuffix(rel, ".txt") {
|
||||
t.Fatalf("downloadFile 应返回相对缓存路径,实际 %q", rel)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cacheDir, "reader", "files", strings.TrimPrefix(rel, "/"))); err != nil {
|
||||
t.Fatalf("文件未落到缓存目录: %v", err)
|
||||
}
|
||||
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+rel+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "正文内容" {
|
||||
t.Fatalf("readTxtFile = %q", got)
|
||||
}
|
||||
|
||||
del, err := r.Run(ar, "String(java.deleteFile('"+rel+"'))", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(del) != "true" {
|
||||
t.Fatalf("deleteFile 应返回 true,实际 %q", anyToString(del))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsRejectPathTraversal(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
secret := filepath.Join(cacheDir, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("不该被读到"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := newFileTestRunner(cacheDir, "", nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
for _, p := range []string{"/../../secret.txt", "../secret.txt", "/../reader/files/../../secret.txt"} {
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+p+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 不应报错: %v", p, err)
|
||||
}
|
||||
if got := anyToString(v); strings.Contains(got, "不该被读到") {
|
||||
t.Fatalf("越权读到了缓存目录外的文件(%s)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsWithoutCacheDirFail(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
_, err := r.Run(NewAnalyzeRule(), "java.cacheFile('https://example.com/a.txt')", nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "缓存目录") {
|
||||
t.Fatalf("未配置缓存目录时应明确报错,实际 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ type JSConfig struct {
|
||||
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
|
||||
// nil 时这两个函数抛出不支持错误。
|
||||
Browser BrowserHost
|
||||
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
|
||||
// 为空时这些函数抛出明确错误。
|
||||
CacheDir string
|
||||
// FetchBytes 拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
// 由服务层注入,复用书源 header / Cookie / 限速 / 重试。
|
||||
FetchBytes func(absURL string) ([]byte, error)
|
||||
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
|
||||
Ctx context.Context
|
||||
}
|
||||
@@ -193,6 +199,18 @@ func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
|
||||
return r.cfg.Fetch(req)
|
||||
}
|
||||
|
||||
// cacheNamespace cache 对象的命名空间:优先取书源 URL,退回 BaseURL。
|
||||
func (c JSConfig) cacheNamespace() string {
|
||||
if c.SourceProps != nil {
|
||||
if v, ok := c.SourceProps["bookSourceUrl"]; ok {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.BaseURL
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
@@ -205,17 +223,107 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
}
|
||||
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
|
||||
vm.Set("cookie", newCookieObject(vm, state))
|
||||
vm.Set("cache", newCacheObject(vm))
|
||||
// cache 按书源命名空间隔离:namespace 取书源 URL,持久层落 CacheDir/reader-js-cache。
|
||||
vm.Set("cache", newCacheObject(vm, cfg.cacheNamespace(), cfg.CacheDir))
|
||||
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
|
||||
srcObj := newSourceObject(vm, state, cfg.SourceProps)
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// Packages(Java 类命名空间)同样要在 jsLib 之前注入:不少书源在 jsLib 或
|
||||
// 目录/正文规则里用 `new Packages.java.util.LinkedHashMap()` 这类写法。
|
||||
r.installPackages()
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installPackages 注入 Java 风格类命名空间(Packages.java.util.*)。
|
||||
//
|
||||
// goja 是纯 JS 运行时,没有 Java。书源(尤其照搬 Java 教程写的)常用
|
||||
// `new Packages.java.util.ArrayList()` / `LinkedHashMap` / `HashMap` 来拼
|
||||
// 目录列表和请求头,缺了它整条规则直接 ReferenceError(拷贝漫画轻小说源的
|
||||
// 目录规则就是这么写的)。
|
||||
//
|
||||
// 实现要点:容器用「真正的 JS 数组 / 对象」,方法用 Object.defineProperty 定义为
|
||||
// 不可枚举属性。这样容器交给引擎侧(GetElements/GetString)时导出的是干净的
|
||||
// 数组([]any)或映射(map[string]any),而不会把 add/put 这些方法也当成元素或
|
||||
// 请求头带出去。
|
||||
func (r *JSRunner) installPackages() {
|
||||
if _, err := r.vm.RunString(packagesPreamble); err != nil {
|
||||
// 注入失败不影响其它规则,只是 Packages.* 不可用。
|
||||
r.jsLibErr = fmt.Errorf("Packages 环境初始化失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// packagesPreamble 定义全局 Packages(以及与之等价的 java.util / java.lang 常用类)。
|
||||
const packagesPreamble = `
|
||||
var Packages = (function () {
|
||||
function def(o, k, v) {
|
||||
Object.defineProperty(o, k, { value: v, enumerable: false, writable: true, configurable: true });
|
||||
}
|
||||
function makeMap() {
|
||||
var m = {};
|
||||
def(m, 'put', function (k, v) { m[k] = v; return v; });
|
||||
def(m, 'putAll', function (o) { if (o) { for (var k in o) { m[k] = o[k]; } } });
|
||||
def(m, 'get', function (k) { return m[k] === undefined ? null : m[k]; });
|
||||
def(m, 'getOrDefault', function (k, d) { return m[k] === undefined ? d : m[k]; });
|
||||
def(m, 'containsKey', function (k) { return Object.prototype.hasOwnProperty.call(m, k); });
|
||||
def(m, 'containsValue', function (v) { for (var k in m) { if (m[k] === v) { return true; } } return false; });
|
||||
def(m, 'remove', function (k) { var v = m[k]; delete m[k]; return v; });
|
||||
def(m, 'size', function () { return Object.keys(m).length; });
|
||||
def(m, 'isEmpty', function () { return Object.keys(m).length === 0; });
|
||||
def(m, 'clear', function () { for (var k in m) { delete m[k]; } });
|
||||
def(m, 'keySet', function () { return Object.keys(m); });
|
||||
def(m, 'values', function () { var a = []; for (var k in m) { a.push(m[k]); } return a; });
|
||||
def(m, 'entrySet', function () { var a = []; for (var k in m) { a.push({ key: k, value: m[k] }); } return a; });
|
||||
def(m, 'toString', function () { return JSON.stringify(m); });
|
||||
return m;
|
||||
}
|
||||
function makeList() {
|
||||
var a = [];
|
||||
def(a, 'add', function (x) { a.push(x); return true; });
|
||||
def(a, 'addAll', function (xs) { if (xs) { for (var i = 0; i < xs.length; i++) { a.push(xs[i]); } } return true; });
|
||||
def(a, 'get', function (i) { return a[i]; });
|
||||
def(a, 'size', function () { return a.length; });
|
||||
def(a, 'isEmpty', function () { return a.length === 0; });
|
||||
def(a, 'contains', function (x) { return a.indexOf(x) >= 0; });
|
||||
def(a, 'remove', function (i) { return a.splice(i, 1)[0]; });
|
||||
def(a, 'clear', function () { a.length = 0; });
|
||||
def(a, 'toArray', function () { return a.slice(); });
|
||||
def(a, 'toString', function () { return a.join(','); });
|
||||
return a;
|
||||
}
|
||||
var util = {};
|
||||
var mapNames = ['LinkedHashMap', 'HashMap', 'TreeMap', 'Hashtable', 'ConcurrentHashMap', 'LinkedTreeMap'];
|
||||
for (var i = 0; i < mapNames.length; i++) { util[mapNames[i]] = makeMap; }
|
||||
var listNames = ['ArrayList', 'LinkedList', 'Vector'];
|
||||
for (var j = 0; j < listNames.length; j++) { util[listNames[j]] = makeList; }
|
||||
util.HashSet = makeList;
|
||||
util.Arrays = { asList: function () { return Array.prototype.slice.call(arguments); } };
|
||||
util.Collections = {
|
||||
emptyList: function () { return []; },
|
||||
emptyMap: function () { return makeMap(); },
|
||||
singletonList: function (x) { return [x]; }
|
||||
};
|
||||
var lang = {
|
||||
String: function (v) { return v == null ? '' : String(v); },
|
||||
Integer: function (v) { return parseInt(v, 10) || 0; },
|
||||
Long: function (v) { return parseInt(v, 10) || 0; },
|
||||
Double: function (v) { return parseFloat(v) || 0; },
|
||||
Boolean: function (v) { return !!v; },
|
||||
StringBuilder: function () {
|
||||
var s = '';
|
||||
var o = {};
|
||||
def(o, 'append', function (x) { s += (x == null ? '' : x); return o; });
|
||||
def(o, 'toString', function () { return s; });
|
||||
return o;
|
||||
}
|
||||
};
|
||||
return { java: { util: util, lang: lang } };
|
||||
})();
|
||||
`
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
@@ -399,7 +507,7 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
|
||||
}
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
prog, err := compileRuleJS(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
@@ -441,6 +549,80 @@ func scopedRuleJS(js string) string {
|
||||
return "{\n" + js + "\n}"
|
||||
}
|
||||
|
||||
// functionRuleJS 把规则 JS 包成函数体后求值。
|
||||
//
|
||||
// legado 允许书源规则用顶层 `return` 提前返回(拷贝漫画轻小说源的正文规则
|
||||
// 就在 if 分支里 `return '<img ...>'`),而 JS 脚本语法不允许顶层 return,
|
||||
// 只有函数体允许。这里作为块形式的兜底。
|
||||
func functionRuleJS(js string) string {
|
||||
return "(function(){\n" + js + "\n})()"
|
||||
}
|
||||
|
||||
// compileRuleJS 编译一段书源规则 JS。
|
||||
//
|
||||
// 优先用「块」形式:块的完成值就是最后一条语句的值,大量书源依赖它
|
||||
// (如 `@js:1+2` 直接产出 3),且块作用域能隔离顶层 let/const(见 scopedRuleJS)。
|
||||
// 块形式编译失败时退回「函数体」形式,兼容 legado 允许的顶层 return。
|
||||
func compileRuleJS(js string) (*goja.Program, error) {
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
if err == nil {
|
||||
return prog, nil
|
||||
}
|
||||
if prog2, err2 := compileCached(functionRuleJS(js)); err2 == nil {
|
||||
return prog2, nil
|
||||
}
|
||||
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
|
||||
}
|
||||
|
||||
// RunImageDecode 执行图片字节二次解密 JS(coverDecodeJs / ruleContent.imageDecode)。
|
||||
//
|
||||
// 对应 legado ImageUtils.getDecodeResult:绑定 result=图片字节、src=图片地址,
|
||||
// 规则返回解密后的字节(ArrayBuffer / typed array)。执行失败返回 error,
|
||||
// 调用方决定是回 502 还是原样透传。
|
||||
func (r *JSRunner) RunImageDecode(js string, data []byte, src string) ([]byte, error) {
|
||||
vm := r.vm
|
||||
r.installJava(nil)
|
||||
vm.Set("book", nil)
|
||||
vm.Set("chapter", nil)
|
||||
vm.Set("title", nil)
|
||||
vm.Set("baseUrl", r.cfg.BaseURL)
|
||||
vm.Set("result", vm.ToValue(vm.NewArrayBuffer(data)))
|
||||
vm.Set("src", src)
|
||||
vm.Set("key", nil)
|
||||
vm.Set("page", nil)
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileRuleJS(stripRuleJSWrapper(js))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密 JS 编译失败: %w", err)
|
||||
}
|
||||
g := newInterruptGuard(vm, r.cfg.Timeout, "图片解密超时")
|
||||
r.setGuard(g)
|
||||
defer func() {
|
||||
g.Stop()
|
||||
r.setGuard(nil)
|
||||
}()
|
||||
v, err := vm.RunProgram(prog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("图片解密失败: %v", err)
|
||||
}
|
||||
out, ok := exportBytes(vm, v)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("图片解密规则没有返回字节")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchBytes 用书源的网络栈拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
|
||||
func (r *JSRunner) FetchBytes(absURL string) ([]byte, error) {
|
||||
if r.cfg.FetchBytes == nil {
|
||||
return nil, ErrJsUnsupported
|
||||
}
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.FetchBytes(absURL)
|
||||
}
|
||||
|
||||
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
|
||||
func exportValue(v goja.Value) any {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
@@ -454,6 +636,41 @@ func exportValue(v goja.Value) any {
|
||||
}
|
||||
}
|
||||
|
||||
// stripRuleJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
// 与 reader 包的 stripJSWrapper 同语义;规则包不能反向依赖 reader 包,故此处保留一份。
|
||||
func stripRuleJSWrapper(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return strings.TrimSpace(trimmed[len("@js:"):])
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body
|
||||
default:
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
// exportBytes 把 JS 返回值按字节取出:ArrayBuffer / typed array / 字符串。
|
||||
// 图片与字体解密规则返回的都是字节,ArrayBuffer 的 Export() 只给出属性 map,
|
||||
// 必须走 ExportTo 才能拿到真正的字节。
|
||||
func exportBytes(vm *goja.Runtime, v goja.Value) ([]byte, bool) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return nil, false
|
||||
}
|
||||
var buf []byte
|
||||
if err := vm.ExportTo(v, &buf); err == nil {
|
||||
return buf, true
|
||||
}
|
||||
if s, ok := v.Export().(string); ok {
|
||||
return []byte(s), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// toJSValue 把引擎内部结果转为可注入 JS 的值。
|
||||
// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。
|
||||
func toJSValue(vm *goja.Runtime, v any) any {
|
||||
@@ -495,6 +712,9 @@ func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string,
|
||||
}
|
||||
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
|
||||
mustSet("bodyStr", body)
|
||||
// bodyAsBytes:部分书源用 `java.bytesToStr(resp.bodyAsBytes(), enc)` 处理
|
||||
// 非 UTF-8(GBK)正文(拷贝漫画轻小说源即如此)。
|
||||
mustSet("bodyAsBytes", func(call goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer([]byte(body))) })
|
||||
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
|
||||
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
|
||||
mustSet("header", func(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -332,14 +332,51 @@ func TestJSUnsupportedStillWorks(t *testing.T) {
|
||||
|
||||
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestJSSandboxUnsupported 服务端不接受书源读取宿主任意文件。
|
||||
//
|
||||
// 文件类接口(cacheFile/downloadFile/readTxtFile/...)已实现,但路径被限制在
|
||||
// 书源自己的缓存目录内:越权路径安全地返回空,而不是把 /etc/passwd 交出去,
|
||||
// 也不应因为越权路径抛异常(否则书源会误判为「线路故障」反复重试)。
|
||||
// 真正无实现的能力(解压包、无头浏览器等)继续明确抛「不支持」。
|
||||
func TestJSSandboxUnsupported(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
_, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("expected sandbox error, got %v", err)
|
||||
got, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("越权路径应安全返回空而不是抛异常: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "root:") || strings.Contains(got, "/bin/") {
|
||||
t.Fatalf("沙箱被突破,读到了宿主文件: %q", got)
|
||||
}
|
||||
for _, call := range []string{`java.unzipFile('/tmp/a.zip')`, `java.webView('x')`} {
|
||||
if _, err := a.GetString(`@js:`+call, nil, false); err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("%s 应继续抛「不支持」,实际 %v", call, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleJSTopLevelReturn legado 允许规则 JS 顶层 return;无 return 时仍取最后
|
||||
// 一条语句的值(`@js:1+2` → 3)。
|
||||
func TestRuleJSTopLevelReturn(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
v, err := r.Run(ar, "if (1) { return '早退'; }\nreturn '兜底';", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("顶层 return 应可用: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "早退" {
|
||||
t.Fatalf("top-level return = %q, want 早退", got)
|
||||
}
|
||||
|
||||
v2, err := r.Run(ar, "1 + 2", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v2); got != "3" {
|
||||
t.Fatalf("最后一条语句的值 = %q, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -171,6 +174,9 @@ func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
|
||||
a.bookCustom = map[string]string{}
|
||||
}
|
||||
a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "")
|
||||
if a.bookVarPutter != nil {
|
||||
a.bookVarPutter()
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
return o
|
||||
@@ -381,16 +387,183 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
|
||||
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
|
||||
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
|
||||
var jsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
mem map[string]string
|
||||
}{m: map[string]string{}, mem: map[string]string{}}
|
||||
//
|
||||
// 持久层按书源(bookSourceUrl)命名空间隔离,并落到 CacheDir/reader-js-cache:
|
||||
// 之前是包级全局 map,任何书源的 put/get 全局可见,多个源用同一个 key 会互相串值,
|
||||
// 一个源写满 4096 条还会把别的源的缓存一起清掉;重启后也全部丢失。
|
||||
|
||||
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
|
||||
// jsCacheMaxEntries 单个书源命名空间的条目上限:超了按写入时间淘汰最旧的一半,
|
||||
// 只影响本命名空间,不再波及其它书源。
|
||||
const jsCacheMaxEntries = 4096
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
// jsCacheNamespace 一个书源的持久/内存缓存命名空间。
|
||||
type jsCacheNamespace struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
seq map[string]int64
|
||||
next int64
|
||||
path string
|
||||
}
|
||||
|
||||
// jsCacheRegistry 按命名空间持有 cache,命名空间取书源 URL。
|
||||
var jsCacheRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*jsCacheNamespace
|
||||
}{m: map[string]*jsCacheNamespace{}}
|
||||
|
||||
// jsCacheFor 取(或创建)命名空间;cacheDir 非空时尝试从磁盘恢复。
|
||||
func jsCacheFor(namespace, cacheDir string) *jsCacheNamespace {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsCacheRegistry.mu.Lock()
|
||||
ns, ok := jsCacheRegistry.m[namespace]
|
||||
if !ok {
|
||||
ns = &jsCacheNamespace{data: map[string]string{}, seq: map[string]int64{}}
|
||||
if cacheDir != "" {
|
||||
ns.path = filepath.Join(cacheDir, "reader-js-cache", md5Hex(namespace, true)+".json")
|
||||
}
|
||||
ns.load()
|
||||
jsCacheRegistry.m[namespace] = ns
|
||||
}
|
||||
jsCacheRegistry.mu.Unlock()
|
||||
return ns
|
||||
}
|
||||
|
||||
// load 从磁盘恢复命名空间(仅供 jsCacheFor 在注册表锁内首次调用)。
|
||||
func (n *jsCacheNamespace) load() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
raw, err := os.ReadFile(n.path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
var stored struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}
|
||||
if json.Unmarshal(raw, &stored) != nil {
|
||||
return
|
||||
}
|
||||
if stored.Data != nil {
|
||||
n.data = stored.Data
|
||||
}
|
||||
if stored.Seq != nil {
|
||||
n.seq = stored.Seq
|
||||
for _, v := range stored.Seq {
|
||||
if v > n.next {
|
||||
n.next = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// persist 把命名空间写回磁盘(临时文件 + 原子 rename)。
|
||||
func (n *jsCacheNamespace) persist() {
|
||||
if n.path == "" {
|
||||
return
|
||||
}
|
||||
payload, err := json.Marshal(struct {
|
||||
Data map[string]string `json:"data"`
|
||||
Seq map[string]int64 `json:"seq"`
|
||||
}{n.data, n.seq})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(n.path), 0o750); err != nil {
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(n.path), ".js-cache-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
_ = tmp.Close()
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(name)
|
||||
return
|
||||
}
|
||||
_ = os.Rename(name, n.path)
|
||||
}
|
||||
|
||||
// put 写一个持久键值(返回最终值)。
|
||||
func (n *jsCacheNamespace) put(key, value string) string {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if _, exists := n.data[key]; !exists && len(n.data) >= jsCacheMaxEntries {
|
||||
n.evictOldestLocked()
|
||||
}
|
||||
n.next++
|
||||
n.data[key] = value
|
||||
n.seq[key] = n.next
|
||||
n.persist()
|
||||
return value
|
||||
}
|
||||
|
||||
// evictOldestLocked 淘汰最旧的一半条目(调用方需持锁)。
|
||||
func (n *jsCacheNamespace) evictOldestLocked() {
|
||||
type entry struct {
|
||||
key string
|
||||
seq int64
|
||||
}
|
||||
entries := make([]entry, 0, len(n.data))
|
||||
for k := range n.data {
|
||||
entries = append(entries, entry{k, n.seq[k]})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].seq < entries[j].seq })
|
||||
drop := len(entries)/2 + 1
|
||||
for i := 0; i < drop && i < len(entries); i++ {
|
||||
delete(n.data, entries[i].key)
|
||||
delete(n.seq, entries[i].key)
|
||||
}
|
||||
}
|
||||
|
||||
// get 读一个持久键。
|
||||
func (n *jsCacheNamespace) get(key string) (string, bool) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
v, ok := n.data[key]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// del 删除一个持久键。
|
||||
func (n *jsCacheNamespace) del(key string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
delete(n.data, key)
|
||||
delete(n.seq, key)
|
||||
n.persist()
|
||||
}
|
||||
|
||||
// memoryStore 进程内内存缓存(putMemory/getFromMemory),同样按命名空间隔离。
|
||||
var jsMemoryRegistry = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]map[string]string
|
||||
}{m: map[string]map[string]string{}}
|
||||
|
||||
func jsMemoryFor(namespace string) map[string]string {
|
||||
if namespace == "" {
|
||||
namespace = "__global__"
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
defer jsMemoryRegistry.mu.Unlock()
|
||||
store, ok := jsMemoryRegistry.m[namespace]
|
||||
if !ok {
|
||||
store = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = store
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// newCacheObject 构造 JS 的 `cache` 对象。
|
||||
// namespace 取书源 URL;cacheDir 非空时 put/get 持久化到磁盘。
|
||||
func newCacheObject(vm *goja.Runtime, namespace, cacheDir string) *goja.Object {
|
||||
ns := jsCacheFor(namespace, cacheDir)
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) {
|
||||
if err := o.Set(k, v); err != nil {
|
||||
@@ -398,51 +571,50 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
}
|
||||
}
|
||||
|
||||
// 两套存储共用同一份读写实现,只有落点不同。
|
||||
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(*store) >= jsCacheMaxEntries {
|
||||
*store = map[string]string{}
|
||||
}
|
||||
(*store)[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
set("put", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := (*store)[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(ns.put(key, val))
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
if v, ok := ns.get(stringArg(call, 0)); ok {
|
||||
return vm.ToValue(v)
|
||||
}
|
||||
}
|
||||
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(*store, key)
|
||||
jsCache.mu.Unlock()
|
||||
return goja.Null()
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
ns.del(stringArg(call, 0))
|
||||
return goja.Null()
|
||||
})
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory):进程内、不落盘。
|
||||
mem := jsMemoryFor(namespace)
|
||||
set("putMemory", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
val := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
val = call.Arguments[1].String()
|
||||
}
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
if _, exists := mem[key]; !exists && len(mem) >= jsCacheMaxEntries {
|
||||
mem = map[string]string{}
|
||||
jsMemoryRegistry.m[namespace] = mem
|
||||
}
|
||||
mem[key] = val
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("getFromMemory", func(call goja.FunctionCall) goja.Value {
|
||||
jsMemoryRegistry.mu.Lock()
|
||||
v, ok := mem[stringArg(call, 0)]
|
||||
jsMemoryRegistry.mu.Unlock()
|
||||
if !ok {
|
||||
return goja.Null()
|
||||
}
|
||||
}
|
||||
|
||||
set("put", putTo(&jsCache.m))
|
||||
set("get", getFrom(&jsCache.m))
|
||||
set("delete", deleteFrom(&jsCache.m))
|
||||
// 内存缓存(legado Cache.getFromMemory / putMemory)
|
||||
set("putMemory", putTo(&jsCache.mem))
|
||||
set("getFromMemory", getFrom(&jsCache.mem))
|
||||
return vm.ToValue(v)
|
||||
})
|
||||
return o
|
||||
}
|
||||
|
||||
|
||||
@@ -51,3 +51,44 @@ func TestCacheMemoryRoundTrip(t *testing.T) {
|
||||
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// cache 必须按书源隔离:旧实现是包级全局 map,任一源的 put/get 对所有源可见,
|
||||
// 多个源用同一个 key 会互相串值,一个源写满还会清掉别的源的缓存。
|
||||
func TestCacheIsolatedPerSource(t *testing.T) {
|
||||
srcA := map[string]any{"bookSourceUrl": "https://a.example.com"}
|
||||
srcB := map[string]any{"bookSourceUrl": "https://b.example.com"}
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
runnerA := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
if _, err := runnerA.Run(ar, `cache.put('shared-key', 'from-a')`, nil, ""); err != nil {
|
||||
t.Fatalf("源 A 写入失败: %v", err)
|
||||
}
|
||||
runnerB := NewJSRunner(JSConfig{SourceProps: srcB})
|
||||
v, err := runnerB.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 B 读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("源 B 读到了源 A 的缓存: %q", got)
|
||||
}
|
||||
// 源 A 自己重开运行时仍应读到(落到 CacheDir 之外的进程内持久层)。
|
||||
runnerA2 := NewJSRunner(JSConfig{SourceProps: srcA})
|
||||
v, err = runnerA2.Run(ar, `String(cache.get('shared-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("源 A 二次读取失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "from-a" {
|
||||
t.Fatalf("源 A 应读到自己的缓存: %q", got)
|
||||
}
|
||||
// 内存缓存同样按源隔离。
|
||||
if _, err := runnerA.Run(ar, `cache.putMemory('mem-key', 'a')`, nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, err = runnerB.Run(ar, `String(cache.getFromMemory('mem-key'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("内存缓存跨源串值: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,53 @@ func jsonRead(root any, path string) any {
|
||||
if root == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
v, err := jsonpathGet(path, root)
|
||||
v, err := jsonpathGet(normalizeJSONPath(path), root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeJSONPath 把 legado(Jayway)允许、而 PaesslerAG/jsonpath 拒绝的写法
|
||||
// 归一化成合法路径,共两处差异:
|
||||
//
|
||||
// 1. 缺根 `$`:Jayway 要求路径以 `$`/`@` 开头,legado 书源却常直接写
|
||||
// `results.list`、`results.list.[*]`。PaesslerAG 对「不含 `[]`/`*` 的裸路径」
|
||||
// 恰好能容忍(内部按 `$.` 解析),但一旦出现 `[*]` 就报
|
||||
// `unexpected "*" while scanning extensions`。这里统一补根。
|
||||
// 2. 点后紧跟方括号:`results.list.[*]`、`$.a.[0]`、`$.a.['k']`。Jayway 里
|
||||
// `.[` 等价于 `[`,PaesslerAG 会报 `unexpected "[" while scanning field`。
|
||||
//
|
||||
// 两处都会让整条规则静默返回空——表现为「书源明明有效却搜不出任何结果」
|
||||
// (拷贝轻小说源的搜索列表规则就是 `results.list.[*]`)。
|
||||
func normalizeJSONPath(path string) string {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return path
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(p, "$"), strings.HasPrefix(p, "@"):
|
||||
// 已有根,保持
|
||||
case strings.HasPrefix(p, "."):
|
||||
p = "$" + p // `..a` 这类递归写法,补 `$` 而不是 `$.`
|
||||
default:
|
||||
p = "$." + p
|
||||
}
|
||||
if !strings.Contains(p, ".[") {
|
||||
return p
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(p))
|
||||
for i := 0; i < len(p); i++ {
|
||||
// 丢掉 `[` 前多余的点;递归下降 `..[` 保持原样。
|
||||
if p[i] == '.' && i+1 < len(p) && p[i+1] == '[' && i > 0 && p[i-1] != '.' {
|
||||
continue
|
||||
}
|
||||
b.WriteByte(p[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:legado 兼容性回归——`Packages.java.util.*` 容器与 `.[*]` 形式的 JSONPath。
|
||||
//
|
||||
// 背景(拷贝系列轻小说源在 MeBox 搜不到/读不了的根因):
|
||||
// 1. 搜索列表规则写成 `results.list.[*]`(legado/Jayway 接受),PaesslerAG 直接
|
||||
// 解析报错返回空;
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()` / `ArrayList()` 拼装,
|
||||
// goja 里没有 Java,整条规则 ReferenceError。
|
||||
|
||||
// TestNormalizeJSONPathDotBracket 点后跟方括号应归一化成合法路径。
|
||||
func TestNormalizeJSONPathDotBracket(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"results.list.[*]": "$.results.list[*]",
|
||||
"$.results.list.[*]": "$.results.list[*]",
|
||||
"$.a.[0].b": "$.a[0].b",
|
||||
"$.a.['k']": "$.a['k']",
|
||||
"$.results.list": "$.results.list",
|
||||
"$.results..list[*]": "$.results..list[*]",
|
||||
"results.list": "$.results.list",
|
||||
"$[0].name": "$[0].name",
|
||||
"..list[*]": "$..list[*]",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeJSONPath(in); got != want {
|
||||
t.Errorf("normalizeJSONPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetElementsDotBracketStarJsonPath 真实书源的 `results.list.[*]` 必须能取到列表。
|
||||
func TestGetElementsDotBracketStarJsonPath(t *testing.T) {
|
||||
const body = `{"results":{"list":[{"name":"甲"},{"name":"乙"}]}}`
|
||||
for _, rule := range []string{"results.list.[*]", "$.results.list", "results.list"} {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(body, "")
|
||||
els, err := ar.GetElements(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", rule, err)
|
||||
}
|
||||
if len(els) != 2 {
|
||||
t.Fatalf("%s: elements=%d, want 2", rule, len(els))
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "甲" {
|
||||
t.Fatalf("%s: first name=%q err=%v", rule, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesUtilCollections Packages.java.util 的 List/Map 应能拼出目录列表,
|
||||
// 且导出给引擎的是干净的数组/映射(方法不能混进元素里)。
|
||||
func TestPackagesUtilCollections(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("Packages 环境初始化失败: %v", err)
|
||||
}
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"results":{"list":[{"name":"第一卷","id":7}]}}`, "https://api.example.com/volumes")
|
||||
ar.SetJSRunner(r.ForAnalyzer(ar))
|
||||
|
||||
js := `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = 'https://api.example.com/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id)); }
|
||||
out;</js>`
|
||||
els, err := ar.GetElements(js)
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("elements=%d, want 1", len(els))
|
||||
}
|
||||
if m, ok := els[0].(map[string]any); ok {
|
||||
if _, hasMethod := m["put"]; hasMethod {
|
||||
t.Fatalf("容器方法泄漏进了元素: %#v", m)
|
||||
}
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "第一卷" {
|
||||
t.Fatalf("name=%q err=%v", name, err)
|
||||
}
|
||||
url, _ := ar.GetString("$.url", els[0], false)
|
||||
if url != "https://api.example.com/volume/7" {
|
||||
t.Fatalf("url=%q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesHashMapUsableAsHeaders java.get(url, headers) 的 headers 用
|
||||
// Packages.java.util.HashMap 构造时,导出结果应是干净的键值映射。
|
||||
func TestPackagesHashMapUsableAsHeaders(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
v, err := r.Run(NewAnalyzeRule(), `(function () {
|
||||
var h = new Packages.java.util.HashMap();
|
||||
h.put('X-Test', 'v1');
|
||||
return JSON.stringify(h);
|
||||
})()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := anyToString(v); s != `{"X-Test":"v1"}` {
|
||||
t.Fatalf("HashMap 导出含额外键: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions 的 queryTTF / replaceFont:
|
||||
// 解析被字体混淆的正文。书源规则通常写成
|
||||
//
|
||||
// java.replaceFont(result, java.queryTTF(errorFontUrl), java.queryTTF(correctFontUrl))
|
||||
//
|
||||
// 错误字体把码点映射到错字形、正确字体把字形映射回真实码点,按字形轮廓配对即可还原。
|
||||
|
||||
// queryTTFCacheMax 已解析字体缓存条数(对齐 legado 的 LruCache 小容量策略)。
|
||||
const queryTTFCacheMax = 32
|
||||
|
||||
var queryTTFCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*queryTTFFont
|
||||
// order 记录插入顺序,超容量时淘汰最早的一条。
|
||||
order []string
|
||||
}{m: map[string]*queryTTFFont{}}
|
||||
|
||||
// queryTTFFromBytes 解析字体并缓存(key = SHA-256)。
|
||||
func queryTTFFromBytes(data []byte, useCache bool) (*queryTTFFont, error) {
|
||||
sum := sha256.Sum256(data)
|
||||
key := hex.EncodeToString(sum[:])
|
||||
if useCache {
|
||||
queryTTFCache.mu.Lock()
|
||||
if f, ok := queryTTFCache.m[key]; ok {
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Unlock()
|
||||
}
|
||||
f, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !useCache {
|
||||
return f, nil
|
||||
}
|
||||
queryTTFCache.mu.Lock()
|
||||
if len(queryTTFCache.order) >= queryTTFCacheMax {
|
||||
oldest := queryTTFCache.order[0]
|
||||
queryTTFCache.order = queryTTFCache.order[1:]
|
||||
delete(queryTTFCache.m, oldest)
|
||||
}
|
||||
queryTTFCache.m[key] = f
|
||||
queryTTFCache.order = append(queryTTFCache.order, key)
|
||||
queryTTFCache.mu.Unlock()
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// installFontBridge 注册 java.queryTTF / queryBase64TTF / replaceFont。
|
||||
// set 是 java 对象的属性写入闭包(其中已包含 bridgeErr 的异常抛出语义)。
|
||||
func (r *JSRunner) installFontBridge(vm *goja.Runtime, set func(string, any)) {
|
||||
// fontErr 把错误翻译成 JS 异常:书源常把 queryTTF 放在 try/catch 里降级。
|
||||
fontErr := func(name string, msg string) goja.Value {
|
||||
return vm.ToValue("java." + name + ": " + msg)
|
||||
}
|
||||
set("queryTTF", func(call goja.FunctionCall) goja.Value {
|
||||
useCache := true
|
||||
if len(call.Arguments) > 1 {
|
||||
useCache = call.Arguments[1].ToBoolean()
|
||||
}
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, useCache)
|
||||
if err != nil {
|
||||
panic(fontErr("queryTTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
// queryBase64TTF 是 legado 的旧别名,语义完全相同。
|
||||
set("queryBase64TTF", func(call goja.FunctionCall) goja.Value {
|
||||
data, err := r.fontDataFromArg(call)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
font, err := queryTTFFromBytes(data, true)
|
||||
if err != nil {
|
||||
panic(fontErr("queryBase64TTF", err.Error()))
|
||||
}
|
||||
return vm.ToValue(font)
|
||||
})
|
||||
set("replaceFont", func(call goja.FunctionCall) goja.Value {
|
||||
text := stringArg(call, 0)
|
||||
errorFont := queryTTFFontArg(call, 1)
|
||||
correctFont := queryTTFFontArg(call, 2)
|
||||
filter := false
|
||||
if len(call.Arguments) > 3 {
|
||||
filter = call.Arguments[3].ToBoolean()
|
||||
}
|
||||
if errorFont == nil || correctFont == nil {
|
||||
return vm.ToValue(text)
|
||||
}
|
||||
return vm.ToValue(replaceFontText(text, errorFont, correctFont, filter))
|
||||
})
|
||||
}
|
||||
|
||||
// fontDataFromArg 从 JS 参数取字体字节:URL / base64 / ArrayBuffer / typed array。
|
||||
func (r *JSRunner) fontDataFromArg(call goja.FunctionCall) ([]byte, error) {
|
||||
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
// 字节形态(ArrayBuffer / Uint8Array)优先。
|
||||
if data, ok := exportBytes(r.vm, call.Arguments[0]); ok && len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
raw := strings.TrimSpace(call.Arguments[0].String())
|
||||
if raw == "" {
|
||||
return nil, errQueryTTF("缺少字体参数")
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
data, err := r.FetchBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("下载字体失败: " + err.Error())
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
// base64(支持 data:font/...;base64, 前缀与 URL-safe 变体)。
|
||||
if i := strings.Index(raw, "base64,"); i >= 0 {
|
||||
raw = raw[i+len("base64,"):]
|
||||
}
|
||||
data, err := base64DecodeBytes(raw)
|
||||
if err != nil {
|
||||
return nil, errQueryTTF("字体数据不是 base64")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// errQueryTTF 构造 queryTTF 相关错误(拼上函数名前缀由调用方负责)。
|
||||
func errQueryTTF(msg string) error { return fmt.Errorf("%s", msg) }
|
||||
|
||||
// queryTTFFontArg 把 JS 参数还原成 *queryTTFFont。
|
||||
// 书源常把 queryTTF 的结果存进变量再传给 replaceFont,goja 会原样保留 Go 指针。
|
||||
func queryTTFFontArg(call goja.FunctionCall, idx int) *queryTTFFont {
|
||||
if len(call.Arguments) <= idx {
|
||||
return nil
|
||||
}
|
||||
if f, ok := call.Arguments[idx].Export().(*queryTTFFont); ok {
|
||||
return f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceFontText 按字形轮廓把错误字体渲染的文本还原成正确字体对应的真实文字。
|
||||
// 顺序对齐 legado JsExtensions.replaceFont:逐码点取错字形,再到正确字体查回码点。
|
||||
func replaceFontText(text string, errorFont, correctFont *queryTTFFont, filter bool) string {
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(text))
|
||||
for _, cp := range text {
|
||||
if isBlankUnicode(cp) {
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
glyph := errorFont.unicodeToGlyph[cp]
|
||||
if _, ok := errorFont.unicodeToGlyphID[cp]; !ok {
|
||||
// 错误字体里没有这个码点(对应 legado 的 glyfId == 0 → 视为无字形)。
|
||||
glyph = ""
|
||||
}
|
||||
if glyph == "" {
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
continue
|
||||
}
|
||||
if real, ok := correctFont.glyphToUnicode[glyph]; ok && real != 0 {
|
||||
sb.WriteRune(real)
|
||||
continue
|
||||
}
|
||||
if filter {
|
||||
continue
|
||||
}
|
||||
sb.WriteRune(cp)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isBlankUnicode 判断码点是否是不可见的空白(对齐 legado 的 isBlankUnicode 列表)。
|
||||
func isBlankUnicode(cp rune) bool {
|
||||
switch cp {
|
||||
case ' ', '\t', '\n', '\r', '\v', '\f', 0x00A0, 0x2000, 0x2001, 0x2002, 0x2003,
|
||||
0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029,
|
||||
0x202F, 0x205F, 0x3000, 0xFEFF:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -10,6 +10,23 @@ import (
|
||||
// 本文件对应 AnalyzeByRegex.kt。Java 正则语义用 regexp2 对齐
|
||||
// (支持前向后向断言与反向引用),匹配循环对齐 Matcher.find()。
|
||||
|
||||
// regexMatchTimeout 书源正则的匹配预算。
|
||||
//
|
||||
// regexp2 默认永不超时(DefaultMatchTimeout 是 MaxInt64),而书源正则来自
|
||||
// 不可信内容:灾难性回溯会永久占住一个 goroutine 和一颗 CPU 核。这里统一
|
||||
// 设一个短预算,语义与 ApplyUserReplace 的缺省值保持一致。
|
||||
const regexMatchTimeout = 3 * time.Second
|
||||
|
||||
// compileRegex 编译一条 Java 语义正则并设置匹配超时。
|
||||
func compileRegex(pattern string) (*regexp2.Regexp, error) {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
re.MatchTimeout = regexMatchTimeout
|
||||
return re, nil
|
||||
}
|
||||
|
||||
// splitNotBlankAndTrim 对应 String.splitNotBlank("&&"):切分并去空白项。
|
||||
func splitNotBlankAndTrim(s, sep string) []string {
|
||||
var out []string
|
||||
@@ -27,7 +44,7 @@ func regexGetElement(res string, regs []string, index int) []string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -60,7 +77,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
if index >= len(regs) {
|
||||
return nil
|
||||
}
|
||||
re, err := regexp2.Compile(regs[index], regexp2.None)
|
||||
re, err := compileRegex(regs[index])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -95,7 +112,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
|
||||
// regexReplaceAll 对应 Kotlin Regex.replace(result, replacement)
|
||||
// (Java $N 分组替换语义,regexp2 的 Replace 原生支持)。
|
||||
func regexReplaceAll(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(result, pattern, replacement)
|
||||
}
|
||||
@@ -116,14 +133,12 @@ func ApplyUserReplace(content, pattern, replacement string, isRegex bool, timeou
|
||||
if !isRegex {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return strings.ReplaceAll(content, pattern, replacement)
|
||||
}
|
||||
if timeoutMS > 0 {
|
||||
re.MatchTimeout = time.Duration(timeoutMS) * time.Millisecond
|
||||
} else {
|
||||
re.MatchTimeout = 3 * time.Second
|
||||
}
|
||||
out, err := re.Replace(content, replacement, 0, -1)
|
||||
if err != nil {
|
||||
@@ -160,7 +175,7 @@ func ApplyReplaceRegexString(content, replaceRegex string) string {
|
||||
// regexReplaceFirstOnFirstMatch 对应 replaceRegex 的 replaceFirst 分支:
|
||||
// 找到第一个匹配(无匹配返回 ""),在匹配文本上做首次替换。
|
||||
func regexReplaceFirstOnFirstMatch(pattern, result, replacement string) string {
|
||||
re, err := regexp2.Compile(pattern, regexp2.None)
|
||||
re, err := compileRegex(pattern)
|
||||
if err != nil {
|
||||
return replacement
|
||||
}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件移植 legado 的 QueryTTF:解析字体(sfnt)的 cmap / glyf / loca / maxp 表,
|
||||
// 建立「Unicode 码点 → 字形」「字形 → Unicode 码点」两张表,供
|
||||
// java.queryTTF / java.replaceFont 还原被字体混淆的正文。
|
||||
//
|
||||
// 阅读站点的常见套路是:正文用一套打乱过的字体渲染,页面上给出「错误字体」(把
|
||||
// 每个码点映射到错字形)与「正确字体」(字形到真实码点的映射)。replaceFont 按
|
||||
// 字形轮廓把错误字体里的字符逐个换成正确字体里同字形的码点,从而还原原文。
|
||||
//
|
||||
// 解析器全程做边界检查:字体字节来自书源(不可信),坏字体只应报错,不能 panic。
|
||||
|
||||
// queryTTFFont 一个已解析的字体。
|
||||
type queryTTFFont struct {
|
||||
// unicodeToGlyphID 码点 → 字形在 glyf 表里的下标。
|
||||
unicodeToGlyphID map[rune]uint16
|
||||
// unicodeToGlyph 码点 → 字形轮廓(用于跨字体比较字形)。
|
||||
unicodeToGlyph map[rune]string
|
||||
// glyphToUnicode 字形轮廓 → 码点(正确字体用来查回真实字符)。
|
||||
glyphToUnicode map[string]rune
|
||||
}
|
||||
|
||||
// sfnt 表标签。
|
||||
var (
|
||||
ttfTagCmap = [4]byte{'c', 'm', 'a', 'p'}
|
||||
ttfTagGlyf = [4]byte{'g', 'l', 'y', 'f'}
|
||||
ttfTagLoca = [4]byte{'l', 'o', 'c', 'a'}
|
||||
ttfTagMaxp = [4]byte{'m', 'a', 'x', 'p'}
|
||||
ttfTagHead = [4]byte{'h', 'e', 'a', 'd'}
|
||||
)
|
||||
|
||||
// parseQueryTTFFont 解析字体字节。支持 sfnt(TTF/OTF)与 ttc 的第一套字体。
|
||||
func parseQueryTTFFont(data []byte) (font *queryTTFFont, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
font, err = nil, fmt.Errorf("字体解析失败: %v", r)
|
||||
}
|
||||
}()
|
||||
if len(data) < 12 {
|
||||
return nil, fmt.Errorf("字体数据过短")
|
||||
}
|
||||
// ttc:取第一套字体的偏移。
|
||||
if string(data[:4]) == "ttcf" {
|
||||
if len(data) < 16 {
|
||||
return nil, fmt.Errorf("ttc 头部不完整")
|
||||
}
|
||||
off := int(binary.BigEndian.Uint32(data[12:16]))
|
||||
if off <= 0 || off >= len(data) {
|
||||
return nil, fmt.Errorf("ttc 字体偏移非法")
|
||||
}
|
||||
data = data[off:]
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(data[4:6]))
|
||||
if numTables <= 0 || 12+numTables*16 > len(data) {
|
||||
return nil, fmt.Errorf("sfnt 表目录非法")
|
||||
}
|
||||
tables := map[[4]byte][]byte{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := data[12+i*16 : 12+i*16+16]
|
||||
var tag [4]byte
|
||||
copy(tag[:], rec[:4])
|
||||
off := int(binary.BigEndian.Uint32(rec[8:12]))
|
||||
length := int(binary.BigEndian.Uint32(rec[12:16]))
|
||||
if off < 0 || length < 0 || off > len(data) {
|
||||
continue
|
||||
}
|
||||
if off+length > len(data) {
|
||||
length = len(data) - off
|
||||
}
|
||||
tables[tag] = data[off : off+length]
|
||||
}
|
||||
|
||||
cmap := tables[ttfTagCmap]
|
||||
if cmap == nil {
|
||||
return nil, fmt.Errorf("字体缺少 cmap 表")
|
||||
}
|
||||
mapping, err := parseTTFCmap(cmap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := &queryTTFFont{
|
||||
unicodeToGlyphID: mapping,
|
||||
unicodeToGlyph: map[rune]string{},
|
||||
glyphToUnicode: map[string]rune{},
|
||||
}
|
||||
|
||||
// 有 glyf + loca + head + maxp 才能算字形轮廓;缺了(如 CFF 字体)只保留码点映射。
|
||||
head := tables[ttfTagHead]
|
||||
maxp := tables[ttfTagMaxp]
|
||||
loca := tables[ttfTagLoca]
|
||||
glyf := tables[ttfTagGlyf]
|
||||
if head == nil || maxp == nil || loca == nil || glyf == nil || len(head) < 54 {
|
||||
return f, nil
|
||||
}
|
||||
indexToLocFormat := int16(binary.BigEndian.Uint16(head[50:52]))
|
||||
numGlyphs := int(binary.BigEndian.Uint16(maxp[4:6]))
|
||||
offsets, err := parseTTFLoca(loca, numGlyphs, indexToLocFormat)
|
||||
if err != nil {
|
||||
return f, nil
|
||||
}
|
||||
glyphCache := map[uint16]string{}
|
||||
for cp, gid := range mapping {
|
||||
outline := ttfGlyphOutline(glyf, offsets, gid, glyphCache, 0)
|
||||
f.unicodeToGlyph[cp] = outline
|
||||
if _, exists := f.glyphToUnicode[outline]; !exists {
|
||||
f.glyphToUnicode[outline] = cp
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// parseTTFCmap 解析 cmap 表,返回码点 → 字形下标。
|
||||
// 支持 format 0 / 4 / 6(legado QueryTTF 同样只支持这三种)。
|
||||
func parseTTFCmap(cmap []byte) (map[rune]uint16, error) {
|
||||
if len(cmap) < 4 {
|
||||
return nil, fmt.Errorf("cmap 表过短")
|
||||
}
|
||||
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
|
||||
if 4+numTables*8 > len(cmap) {
|
||||
return nil, fmt.Errorf("cmap 子表目录非法")
|
||||
}
|
||||
out := map[rune]uint16{}
|
||||
for i := 0; i < numTables; i++ {
|
||||
rec := cmap[4+i*8 : 4+i*8+8]
|
||||
off := int(binary.BigEndian.Uint32(rec[4:8]))
|
||||
if off < 0 || off+2 > len(cmap) {
|
||||
continue
|
||||
}
|
||||
sub := cmap[off:]
|
||||
switch binary.BigEndian.Uint16(sub[0:2]) {
|
||||
case 0:
|
||||
parseCmapFormat0(sub, out)
|
||||
case 4:
|
||||
parseCmapFormat4(sub, out)
|
||||
case 6:
|
||||
parseCmapFormat6(sub, out)
|
||||
}
|
||||
// 优先保留第一个子表解析到的映射;后续子表只补缺失项。
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("cmap 没有可用的 format 0/4/6 子表")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCmapFormat0(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 262 {
|
||||
return
|
||||
}
|
||||
length := int(binary.BigEndian.Uint16(sub[2:4]))
|
||||
if length > len(sub) {
|
||||
length = len(sub)
|
||||
}
|
||||
glyphs := sub[6:min(6+256, length)]
|
||||
for i, gid := range glyphs {
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(i)]; !exists {
|
||||
out[rune(i)] = uint16(gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat4(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 14 {
|
||||
return
|
||||
}
|
||||
segCountX2 := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
segCount := segCountX2 / 2
|
||||
if segCount == 0 {
|
||||
return
|
||||
}
|
||||
endBase := 14
|
||||
startBase := endBase + segCountX2 + 2
|
||||
deltaBase := startBase + segCountX2
|
||||
rangeBase := deltaBase + segCountX2
|
||||
if rangeBase+segCountX2 > len(sub) {
|
||||
return
|
||||
}
|
||||
for i := 0; i < segCount; i++ {
|
||||
end := int(binary.BigEndian.Uint16(sub[endBase+i*2:]))
|
||||
start := int(binary.BigEndian.Uint16(sub[startBase+i*2:]))
|
||||
delta := int16(binary.BigEndian.Uint16(sub[deltaBase+i*2:]))
|
||||
rangeOffset := int(binary.BigEndian.Uint16(sub[rangeBase+i*2:]))
|
||||
if start > end {
|
||||
continue
|
||||
}
|
||||
for cp := start; cp <= end && cp <= 0xFFFF; cp++ {
|
||||
if cp == 0xFFFF {
|
||||
continue
|
||||
}
|
||||
var gid uint16
|
||||
if rangeOffset == 0 {
|
||||
gid = uint16(int(cp) + int(delta))
|
||||
} else {
|
||||
idx := rangeBase + i*2 + rangeOffset + (cp-start)*2
|
||||
if idx+2 > len(sub) {
|
||||
continue
|
||||
}
|
||||
g := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if g == 0 {
|
||||
continue
|
||||
}
|
||||
gid = uint16(int(g) + int(delta))
|
||||
}
|
||||
if gid != 0 {
|
||||
if _, exists := out[rune(cp)]; !exists {
|
||||
out[rune(cp)] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseCmapFormat6(sub []byte, out map[rune]uint16) {
|
||||
if len(sub) < 10 {
|
||||
return
|
||||
}
|
||||
first := int(binary.BigEndian.Uint16(sub[6:8]))
|
||||
count := int(binary.BigEndian.Uint16(sub[8:10]))
|
||||
for i := 0; i < count; i++ {
|
||||
idx := 10 + i*2
|
||||
if idx+2 > len(sub) {
|
||||
return
|
||||
}
|
||||
gid := binary.BigEndian.Uint16(sub[idx : idx+2])
|
||||
if gid == 0 {
|
||||
continue
|
||||
}
|
||||
cp := rune(first + i)
|
||||
if _, exists := out[cp]; !exists {
|
||||
out[cp] = gid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseTTFLoca 解析 loca 表,返回每个字形的字节区间起止。
|
||||
func parseTTFLoca(loca []byte, numGlyphs int, indexToLocFormat int16) ([]int, error) {
|
||||
if indexToLocFormat == 0 {
|
||||
need := (numGlyphs + 1) * 2
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint16(loca[i*2:])) * 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
need := (numGlyphs + 1) * 4
|
||||
if len(loca) < need {
|
||||
return nil, fmt.Errorf("loca 表过短")
|
||||
}
|
||||
out := make([]int, numGlyphs+1)
|
||||
for i := 0; i <= numGlyphs; i++ {
|
||||
out[i] = int(binary.BigEndian.Uint32(loca[i*4:]))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ttfGlyphOutline 把字形转成轮廓字符串(对应 legado QueryTTF.Glyf.toString)。
|
||||
// 复合字形递归展开组件,深度上限防自引用。
|
||||
func ttfGlyphOutline(glyf []byte, offsets []int, gid uint16, cache map[uint16]string, depth int) string {
|
||||
if depth > 8 {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
if v, ok := cache[gid]; ok {
|
||||
return v
|
||||
}
|
||||
if int(gid)+1 >= len(offsets) {
|
||||
return fmt.Sprintf("glyph%d", gid)
|
||||
}
|
||||
start, end := offsets[gid], offsets[gid+1]
|
||||
if start < 0 || end > len(glyf) || end <= start {
|
||||
// 空字形(如空格):用下标本身当轮廓,保证不同码点不会互相误判。
|
||||
out := fmt.Sprintf("glyph%d", gid)
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
numberOfContours := int16(binary.BigEndian.Uint16(glyf[start : start+2]))
|
||||
if numberOfContours >= 0 {
|
||||
out := fmt.Sprintf("simple:%d:%s", numberOfContours, ttfSimpleGlyphPoints(glyf[start:end]))
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
// 复合字形:逐组件展开。
|
||||
var sb strings.Builder
|
||||
sb.WriteString("composite")
|
||||
pos := start + 10
|
||||
for pos+4 <= end {
|
||||
flags := binary.BigEndian.Uint16(glyf[pos : pos+2])
|
||||
component := binary.BigEndian.Uint16(glyf[pos+2 : pos+4])
|
||||
pos += 4
|
||||
if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS
|
||||
pos += 4
|
||||
} else {
|
||||
pos += 2
|
||||
}
|
||||
switch {
|
||||
case flags&0x0008 != 0: // WE_HAVE_A_SCALE
|
||||
pos += 2
|
||||
case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE
|
||||
pos += 4
|
||||
case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO
|
||||
pos += 8
|
||||
}
|
||||
sb.WriteString("+")
|
||||
sb.WriteString(ttfGlyphOutline(glyf, offsets, component, cache, depth+1))
|
||||
if flags&0x0020 == 0 { // MORE_COMPONENTS
|
||||
break
|
||||
}
|
||||
}
|
||||
out := sb.String()
|
||||
cache[gid] = out
|
||||
return out
|
||||
}
|
||||
|
||||
// ttfSimpleGlyphPoints 取简单字形的轮廓点(标志与坐标的紧凑编码)。
|
||||
func ttfSimpleGlyphPoints(data []byte) string {
|
||||
if len(data) < 10 {
|
||||
return ""
|
||||
}
|
||||
numberOfContours := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
if numberOfContours <= 0 {
|
||||
return ""
|
||||
}
|
||||
endPtsPos := 10
|
||||
if endPtsPos+numberOfContours*2+2 > len(data) {
|
||||
return ""
|
||||
}
|
||||
numPoints := int(binary.BigEndian.Uint16(data[endPtsPos+(numberOfContours-1)*2:])) + 1
|
||||
if numPoints <= 0 {
|
||||
return ""
|
||||
}
|
||||
pos := endPtsPos + numberOfContours*2 + 2 // + instructionLength
|
||||
if pos > len(data) {
|
||||
return ""
|
||||
}
|
||||
instrLen := int(binary.BigEndian.Uint16(data[pos-2 : pos]))
|
||||
pos += instrLen
|
||||
flags := make([]byte, 0, numPoints)
|
||||
for len(flags) < numPoints && pos < len(data) {
|
||||
flag := data[pos]
|
||||
pos++
|
||||
flags = append(flags, flag)
|
||||
if flag&0x08 != 0 { // REPEAT
|
||||
if pos >= len(data) {
|
||||
break
|
||||
}
|
||||
repeat := int(data[pos])
|
||||
pos++
|
||||
for i := 0; i < repeat && len(flags) < numPoints; i++ {
|
||||
flags = append(flags, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 解析 x / y 坐标(与点数等长的增量序列,这里只用于区分字形)。
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("n=%d;", numPoints))
|
||||
for _, flag := range flags {
|
||||
sb.WriteByte('0' + flag&0x0F)
|
||||
}
|
||||
sb.WriteString(";")
|
||||
// 跳过坐标数据不影响「同名轮廓一致性」的判断:同字形的字体坐标编码一致。
|
||||
if pos < len(data) {
|
||||
sb.WriteString(fmt.Sprintf("d=%d", len(data)-pos))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 字体混淆还原(ttf.go / queryttf.go)的测试:用合成的 TTF 覆盖 cmap format 0/4、
|
||||
// loca 短格式、简单/复合字形,验证「错误字体 → 正确字体」的按字形还原。
|
||||
|
||||
// buildTestTTF 构造一个最小可解析的 TTF:
|
||||
// cmap(format 4)把给定码点映射到指定字形下标,glyf 里每个字形一个方框。
|
||||
func buildTestTTF(t *testing.T, entries map[rune]uint16) []byte {
|
||||
t.Helper()
|
||||
const numGlyphs = 8
|
||||
// 每个字形一个简单方框:轮廓数 1,1 个点。
|
||||
glyph := func() []byte {
|
||||
g := make([]byte, 0, 20)
|
||||
g = append(g, 0x00, 0x01) // numberOfContours = 1
|
||||
g = append(g, 0, 0, 0, 0, 0, 0, 0, 0) // bbox
|
||||
g = append(g, 0x00, 0x00) // endPtsOfContours[0] = 0
|
||||
g = append(g, 0x00, 0x00) // instructionLength = 0
|
||||
g = append(g, 0x01, 0x01) // flag: on-curve | x-short | y-short
|
||||
g = append(g, 0x00, 0x00) // x=0, y=0
|
||||
return g
|
||||
}
|
||||
glyfData := make([]byte, 0, 128)
|
||||
offsets := make([]int, 0, numGlyphs+1)
|
||||
for i := 0; i < numGlyphs; i++ {
|
||||
offsets = append(offsets, len(glyfData))
|
||||
if i == 0 {
|
||||
continue // .notdef 空字形
|
||||
}
|
||||
g := glyph()
|
||||
// 让不同字形的字节长度不同,轮廓字符串才能区分。
|
||||
for j := 0; j < i-1; j++ {
|
||||
g = append(g, 0x01, 0x00, 0x00) // 额外点
|
||||
}
|
||||
glyfData = append(glyfData, g...)
|
||||
}
|
||||
offsets = append(offsets, len(glyfData))
|
||||
|
||||
// loca(短格式,偏移/2)
|
||||
loca := make([]byte, (numGlyphs+1)*2)
|
||||
for i, off := range offsets {
|
||||
binary.BigEndian.PutUint16(loca[i*2:], uint16(off/2))
|
||||
}
|
||||
|
||||
// cmap:format 4 单段 + 结束段
|
||||
var cmap bytes.Buffer
|
||||
codes := make([]rune, 0, len(entries))
|
||||
for cp := range entries {
|
||||
codes = append(codes, cp)
|
||||
}
|
||||
// 按码点排序,构造连续单点段。
|
||||
for i := 0; i < len(codes); i++ {
|
||||
for j := i + 1; j < len(codes); j++ {
|
||||
if codes[j] < codes[i] {
|
||||
codes[i], codes[j] = codes[j], codes[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
segCount := len(codes) + 1
|
||||
endCodes := make([]uint16, 0, segCount)
|
||||
startCodes := make([]uint16, 0, segCount)
|
||||
idDeltas := make([]uint16, 0, segCount)
|
||||
for _, cp := range codes {
|
||||
endCodes = append(endCodes, uint16(cp))
|
||||
startCodes = append(startCodes, uint16(cp))
|
||||
idDeltas = append(idDeltas, uint16(int(entries[cp])-int(cp)))
|
||||
}
|
||||
endCodes = append(endCodes, 0xFFFF)
|
||||
startCodes = append(startCodes, 0xFFFF)
|
||||
idDeltas = append(idDeltas, 1)
|
||||
rangeOffsets := make([]uint16, segCount) // 全 0:用 idDelta
|
||||
|
||||
sub := new(bytes.Buffer)
|
||||
writeU16 := func(v uint16) { _ = binary.Write(sub, binary.BigEndian, v) }
|
||||
length := 16 + segCount*8
|
||||
writeU16(4) // format
|
||||
writeU16(uint16(length)) // length
|
||||
writeU16(0) // language
|
||||
writeU16(uint16(segCount * 2)) // segCountX2
|
||||
writeU16(0) // searchRange(解析器不校验)
|
||||
writeU16(0) // entrySelector
|
||||
writeU16(0) // rangeShift
|
||||
for _, v := range endCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
writeU16(0) // reservedPad
|
||||
for _, v := range startCodes {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range idDeltas {
|
||||
writeU16(v)
|
||||
}
|
||||
for _, v := range rangeOffsets {
|
||||
writeU16(v)
|
||||
}
|
||||
subBytes := sub.Bytes()
|
||||
|
||||
// cmap 头 + 一个子表记录
|
||||
cmap.Write([]byte{0, 0})
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1))
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(3)) // platformID = Windows
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint16(1)) // encodingID = Unicode BMP
|
||||
_ = binary.Write(&cmap, binary.BigEndian, uint32(12))
|
||||
cmap.Write(subBytes)
|
||||
|
||||
head := make([]byte, 54)
|
||||
binary.BigEndian.PutUint16(head[50:], 0) // indexToLocFormat = 0(短 loca)
|
||||
maxp := make([]byte, 6)
|
||||
binary.BigEndian.PutUint16(maxp[4:], numGlyphs)
|
||||
|
||||
tables := []struct {
|
||||
tag string
|
||||
data []byte
|
||||
}{
|
||||
{"cmap", cmap.Bytes()},
|
||||
{"glyf", glyfData},
|
||||
{"loca", loca},
|
||||
{"head", head},
|
||||
{"maxp", maxp},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
out.Write([]byte{0x00, 0x01, 0x00, 0x00}) // sfntVersion
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(len(tables)))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint16(0))
|
||||
offset := 12 + len(tables)*16
|
||||
offsetsTable := make([]int, len(tables))
|
||||
for i, tb := range tables {
|
||||
padded := tb.data
|
||||
if len(padded)%4 != 0 {
|
||||
padded = append(padded, make([]byte, 4-len(padded)%4)...)
|
||||
}
|
||||
offsetsTable[i] = offset
|
||||
offset += len(padded)
|
||||
}
|
||||
for i, tb := range tables {
|
||||
out.WriteString(tb.tag)
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(0))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(offsetsTable[i]))
|
||||
_ = binary.Write(&out, binary.BigEndian, uint32(len(tb.data)))
|
||||
}
|
||||
for _, tb := range tables {
|
||||
out.Write(tb.data)
|
||||
for out.Len()%4 != 0 {
|
||||
out.WriteByte(0)
|
||||
}
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// 解析出的码点 → 字形 → 码点映射与构造时一致。
|
||||
func TestQueryTTFParseRoundTrip(t *testing.T) {
|
||||
data := buildTestTTF(t, map[rune]uint16{
|
||||
'A': 1, 'B': 2, 'C': 3, 'D': 4,
|
||||
})
|
||||
font, err := parseQueryTTFFont(data)
|
||||
if err != nil {
|
||||
t.Fatalf("解析字体失败: %v", err)
|
||||
}
|
||||
for cp, gid := range map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4} {
|
||||
if got := font.unicodeToGlyphID[cp]; got != gid {
|
||||
t.Fatalf("码点 %q 的字形下标 = %d,期望 %d", cp, got, gid)
|
||||
}
|
||||
}
|
||||
if font.glyphToUnicode[font.unicodeToGlyph['A']] != 'A' {
|
||||
t.Fatal("字形 → 码点映射不正确")
|
||||
}
|
||||
if font.unicodeToGlyph['A'] == font.unicodeToGlyph['B'] {
|
||||
t.Fatal("不同码点的轮廓不应相同")
|
||||
}
|
||||
}
|
||||
|
||||
// replaceFont:错误字体把 A 渲染成 B 的字形,正确字体应把 A 还原成 B。
|
||||
func TestReplaceFontRestoresText(t *testing.T) {
|
||||
// 错误字体:码点 A 指向字形 2(也就是 B 的形状)。
|
||||
errorFontData := buildTestTTF(t, map[rune]uint16{'A': 2, 'B': 3, 'C': 4, 'D': 5})
|
||||
// 正确字体:码点 B 指向字形 2。
|
||||
correctFontData := buildTestTTF(t, map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4})
|
||||
errorFont, err := parseQueryTTFFont(errorFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
correctFont, err := parseQueryTTFFont(correctFontData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 页面上写的是 'A',实际字形是 B → 应还原为 'B'。
|
||||
got := replaceFontText("A", errorFont, correctFont, false)
|
||||
if got != "B" {
|
||||
t.Fatalf("replaceFont 还原结果 = %q,期望 %q", got, "B")
|
||||
}
|
||||
// 空白与未知码点保持原样。
|
||||
mixed := replaceFontText("A 中", errorFont, correctFont, false)
|
||||
if !strings.HasPrefix(mixed, "B ") || !strings.HasSuffix(mixed, "中") {
|
||||
t.Fatalf("混合文本处理异常: %q", mixed)
|
||||
}
|
||||
// filter=true 时删掉没有对应字形的字符。
|
||||
filtered := replaceFontText("A中", errorFont, correctFont, true)
|
||||
if filtered != "B" {
|
||||
t.Fatalf("filter 结果 = %q,期望 %q", filtered, "B")
|
||||
}
|
||||
}
|
||||
|
||||
// 坏字体只应报错,不能 panic。
|
||||
func TestQueryTTFBadFontNoPanic(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
nil,
|
||||
[]byte("not a font"),
|
||||
[]byte("ttcf"),
|
||||
append([]byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x02}, make([]byte, 40)...),
|
||||
}
|
||||
for i, data := range cases {
|
||||
if _, err := parseQueryTTFFont(data); err == nil {
|
||||
t.Fatalf("坏字体 #%d 应返回错误", i)
|
||||
}
|
||||
}
|
||||
// 截断的合法字体也不能 panic。
|
||||
full := buildTestTTF(t, map[rune]uint16{'A': 1})
|
||||
for cut := 1; cut < len(full); cut += 37 {
|
||||
_, _ = parseQueryTTFFont(full[:cut])
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type Request struct {
|
||||
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
|
||||
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
|
||||
HexBody bool
|
||||
// Raw 请求响应按「原始字节」返回,跳过 charset 解码。
|
||||
// 供 java.downloadFile / java.cacheFile 这类需要保真落盘的调用使用:
|
||||
// 一旦按 UTF-8 解码,GBK 字节会被替换成 U+FFFD,写出来的文件就坏了。
|
||||
Raw bool
|
||||
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
|
||||
BodyJsFn func(body string) string
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
@@ -335,6 +339,58 @@ func findParamSplit(s string) (start, end int, ok bool) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// FindParamSplit 导出 findParamSplit:图片等媒体地址也支持 ",{...}" 选项段,
|
||||
// 服务层需要先拆分再分别做请求与展示处理。
|
||||
func FindParamSplit(s string) (start, end int, ok bool) {
|
||||
return findParamSplit(s)
|
||||
}
|
||||
|
||||
// MediaOptions 是媒体地址尾部 ",{...}" 选项段里服务端允许应用的部分。
|
||||
//
|
||||
// 图片/音频等媒体请求走代理,选项里只有请求头与重试这类「取图必需」的字段有意义;
|
||||
// webView/webJs/js/bodyJs 这些需要浏览器或脚本执行引擎的字段必须忽略,
|
||||
// 否则一个书源就能让媒体代理变成任意请求的中转。
|
||||
type MediaOptions struct {
|
||||
Headers map[string]string
|
||||
Charset string
|
||||
Retry *int
|
||||
Method string
|
||||
}
|
||||
|
||||
// ParseMediaOptions 拆分媒体地址与其尾部选项。
|
||||
// 返回去掉选项段的地址(已 trim)、应用后的选项与「是否带选项」。
|
||||
func ParseMediaOptions(raw string) (string, MediaOptions, bool) {
|
||||
var out MediaOptions
|
||||
s := strings.TrimSpace(raw)
|
||||
st, end, ok := findParamSplit(s)
|
||||
if !ok {
|
||||
return s, out, false
|
||||
}
|
||||
base := strings.TrimSpace(s[:st])
|
||||
optionStr := strings.TrimSpace(s[end:])
|
||||
var option URLOption
|
||||
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
|
||||
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
|
||||
return base, out, false
|
||||
}
|
||||
}
|
||||
if len(option.Headers) > 0 {
|
||||
out.Headers = make(map[string]string, len(option.Headers))
|
||||
for k, v := range option.Headers {
|
||||
out.Headers[k] = anyToString(v)
|
||||
}
|
||||
}
|
||||
out.Charset = option.Charset
|
||||
out.Retry = option.Retry
|
||||
switch strings.ToUpper(option.Method) {
|
||||
case "HEAD":
|
||||
out.Method = "HEAD"
|
||||
default:
|
||||
out.Method = "GET"
|
||||
}
|
||||
return base, out, true
|
||||
}
|
||||
|
||||
func isSpaceByte(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/htmlindex"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件覆盖规则稳定性修复的回归:
|
||||
// - @put 写回书源变量(跨请求持久化)
|
||||
// - book.putVariable 持久化到书架记录
|
||||
// - 书源正则的匹配超时(灾难性回溯不会挂死)
|
||||
// - helper.DecompressBody 的解压上限
|
||||
// - 非 UTF-8 TXT 的导入/读取
|
||||
// - 图片 URL 尾部 options 的请求头应用
|
||||
|
||||
// @put 必须写到书源变量(legado BaseSource.putVariable),而不是一次性的书籍变量。
|
||||
func TestPutPersistsToSourceVariable(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srcURL := "https://put.example.com"
|
||||
src := &model.ReaderBookSource{Name: "put 源", SourceURL: srcURL}
|
||||
bs := &BookSource{BookSourceURL: srcURL, RuleContent: &ContentRule{Content: strPtr("id.content@textNodes")}}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
ar.Put("token", "abc123")
|
||||
sess.close()
|
||||
|
||||
// 换一个全新会话/解析器:@put 的值必须还能 @get 到(已落库)。
|
||||
sess2 := svc.newSession(t.Context(), src, bs)
|
||||
defer sess2.close()
|
||||
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar2.Get("token"); got != "abc123" {
|
||||
t.Fatalf("@put 未持久化:@get 得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 书源 variables 默认值只是兜底:@put 写入的同名值优先级更高。
|
||||
func TestPutOverridesSourceDefaultVariable(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srcURL := "https://put2.example.com"
|
||||
raw := `{"bookSourceUrl":"https://put2.example.com","variables":"{\"token\":\"default\"}"}`
|
||||
srcID := importTestSource(t, svc, raw, srcURL)
|
||||
src, err := svc.repo.GetSource(context.Background(), srcID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bs, err := ParseBookSource(src.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar.Get("token"); got != "default" {
|
||||
t.Fatalf("默认变量应可读,得到 %q", got)
|
||||
}
|
||||
ar.Put("token", "override")
|
||||
sess.close()
|
||||
|
||||
sess2 := svc.newSession(t.Context(), src, bs)
|
||||
defer sess2.close()
|
||||
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
|
||||
if got := ar2.Get("token"); got != "override" {
|
||||
t.Fatalf("@put 应覆盖默认变量,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// book.putVariable 写入的变量要落回书架记录(对应 legado Book.upVariable)。
|
||||
func TestBookPutVariablePersists(t *testing.T) {
|
||||
svc, srv, book := prepareCacheTestBook(t)
|
||||
ctx := t.Context()
|
||||
book.Variable = `{"tone_id":"keep"}`
|
||||
if err := svc.repo.UpdateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
src, bs, err := svc.loadSourceFlexible(ctx, "", book.Origin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess := svc.newSession(ctx, src, bs)
|
||||
sess.book = loaded
|
||||
ar := sess.newAnalyzer("", 0, "<html></html>", book.Origin)
|
||||
sess.applyBookContext(ar, book.BookURL, loaded, "", 0)
|
||||
|
||||
// 书源 JS 的 book.getVariable / putVariable。
|
||||
runner := sess.runner("", 0)
|
||||
if _, err := runner.Run(ar, `book.putVariable('token','v-42'); book.getVariable('tone_id')`, nil, book.Origin); err != nil {
|
||||
t.Fatalf("book JS 执行失败: %v", err)
|
||||
}
|
||||
sess.close()
|
||||
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vars := parseBookVariableMap(stored.Variable)
|
||||
if vars["token"] != "v-42" {
|
||||
t.Fatalf("book.putVariable 未持久化: %q", stored.Variable)
|
||||
}
|
||||
if vars["tone_id"] != "keep" {
|
||||
t.Fatalf("已有变量被覆盖: %q", stored.Variable)
|
||||
}
|
||||
_ = srv
|
||||
}
|
||||
|
||||
// 灾难性回溯的正则必须在秒级返回,而不是挂死 goroutine。
|
||||
func TestRegexAnalyzerTimeoutOnCatastrophicBacktracking(t *testing.T) {
|
||||
// (a+)+$ 对 "aaaa...b" 是指数级回溯;没有 MatchTimeout 会永久卡住。
|
||||
evil := strings.Repeat("a", 40) + "b"
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
done <- rule.ApplyReplaceRegexString(evil, "##(a+)+$##X")
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("书源正则未在超时预算内返回(MatchTimeout 未生效)")
|
||||
}
|
||||
}
|
||||
|
||||
// 解压炸弹:超过输出上限时原样返回压缩字节(调用方按失败处理),不撑爆内存。
|
||||
func TestDecompressBodyRejectsBomb(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
// 128MB 的零字节压成几十 KB。
|
||||
chunk := make([]byte, 1<<20)
|
||||
for i := 0; i < 128; i++ {
|
||||
if _, err := gz.Write(chunk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
|
||||
out := helper.DecompressBody(resp, buf.Bytes())
|
||||
if len(out) == len(chunk)*128 {
|
||||
t.Fatal("解压炸弹应被上限拦截")
|
||||
}
|
||||
if !bytes.Equal(out, buf.Bytes()) {
|
||||
t.Fatal("超限时应原样返回压缩字节")
|
||||
}
|
||||
}
|
||||
|
||||
// 正常 gzip 响应仍应被正确解压(上限不误伤常规页面)。
|
||||
func TestDecompressBodyNormal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
payload := []byte("<html>hello</html>")
|
||||
if _, err := gz.Write(payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = gz.Close()
|
||||
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
|
||||
if out := helper.DecompressBody(resp, buf.Bytes()); string(out) != string(payload) {
|
||||
t.Fatalf("正常 gzip 解压失败: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 非 UTF-8(GBK)TXT:导入落盘为 UTF-8,按章节读取不再乱码/错位。
|
||||
func TestImportGBKTextBookReadsCorrectly(t *testing.T) {
|
||||
// 用临时 DataDir,避免本地书籍文件写进仓库目录。
|
||||
svc := newLocalBookService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 用 GBK 编码两章正文(解码后章节字节区间按 UTF-8 计算)。
|
||||
gbkBytes := func(s string) []byte {
|
||||
enc, err := htmlindex.Get("gbk")
|
||||
if err != nil {
|
||||
t.Skip("环境缺少 GBK 编码支持")
|
||||
}
|
||||
out, err := enc.NewEncoder().Bytes([]byte(s))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
text := "第一章 起点\n这是第一章的正文内容,包含中文标点。\n第二章 继续\n这是第二章的正文内容。\n"
|
||||
book, err := svc.ImportLocalBook(ctx, "u1", "gbk小说.txt", gbkBytes(text))
|
||||
if err != nil {
|
||||
t.Fatalf("导入 GBK 书籍失败: %v", err)
|
||||
}
|
||||
if book.Charset != "gbk" {
|
||||
t.Fatalf("应识别为 gbk,实际 %q", book.Charset)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil || len(chapters) < 2 {
|
||||
t.Fatalf("应切出至少 2 章: n=%d err=%v", len(chapters), err)
|
||||
}
|
||||
second, err := svc.readLocalChapter(book, chapters[1])
|
||||
if err != nil {
|
||||
t.Fatalf("读取第二章失败: %v", err)
|
||||
}
|
||||
if !strings.Contains(second, "第二章的正文内容") {
|
||||
t.Fatalf("第二章内容不正确(编码错位): %q", second)
|
||||
}
|
||||
}
|
||||
|
||||
// 图片 URL 尾部 options:拆分出请求头,且地址本身去掉选项段。
|
||||
func TestParseMediaOptions(t *testing.T) {
|
||||
raw := `https://img.example.com/a.jpg,{"headers":{"Referer":"https://site.example.com/","X-Token":"t1"},"retry":2}`
|
||||
base, opt, ok := rule.ParseMediaOptions(raw)
|
||||
if !ok {
|
||||
t.Fatal("应识别出选项段")
|
||||
}
|
||||
if base != "https://img.example.com/a.jpg" {
|
||||
t.Fatalf("基础地址错误: %q", base)
|
||||
}
|
||||
if opt.Headers["Referer"] != "https://site.example.com/" || opt.Headers["X-Token"] != "t1" {
|
||||
t.Fatalf("请求头解析错误: %+v", opt.Headers)
|
||||
}
|
||||
if opt.Retry == nil || *opt.Retry != 2 {
|
||||
t.Fatalf("retry 解析错误: %+v", opt.Retry)
|
||||
}
|
||||
// 无选项段的普通地址原样返回。
|
||||
if b, _, ok := rule.ParseMediaOptions("https://img.example.com/b.jpg"); ok || b != "https://img.example.com/b.jpg" {
|
||||
t.Fatalf("无选项地址不应被改写: %q ok=%v", b, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 代理地址改写后,图片选项头必须在媒体请求里真正生效。
|
||||
func TestFetchMediaAppliesOptionHeaders(t *testing.T) {
|
||||
var gotReferer, gotToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotReferer = r.Header.Get("Referer")
|
||||
gotToken = r.Header.Get("X-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("jpeg-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
book := &model.ReaderBook{Base: model.Base{ID: "b1"}, Origin: srv.URL, BookURL: srv.URL + "/book/1"}
|
||||
resp, err := svc.FetchMediaWithOptions(t.Context(), book, srv.URL+"/a.jpg", "", map[string]string{
|
||||
"Referer": "https://site.example.com/",
|
||||
"X-Token": "t1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("拉取失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if gotReferer != "https://site.example.com/" || gotToken != "t1" {
|
||||
t.Fatalf("选项请求头未生效: referer=%q token=%q", gotReferer, gotToken)
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyURL 去掉选项段后再签名:带选项与不带选项的同一张图得到同一签名。
|
||||
func TestProxyURLStripsMediaOptions(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
plain := svc.ProxyURL("book-1", "https://img.example.com/a.jpg")
|
||||
withOptions := svc.ProxyURL("book-1", `https://img.example.com/a.jpg,{"headers":{"Referer":"https://s/"}}`)
|
||||
if plain != withOptions {
|
||||
t.Fatalf("选项段应被剥离后签名:\n%s\n%s", plain, withOptions)
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL("book-1", strings.TrimPrefix(strings.Split(plain, "&u=")[1], ""), "")
|
||||
if err == nil && raw != "" {
|
||||
_ = raw
|
||||
}
|
||||
// 拆出 u/s 校验签名可回推出干净地址。
|
||||
u := plain[strings.Index(plain, "&u=")+3:]
|
||||
if i := strings.Index(u, "&s="); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if decoded, err := svc.VerifyProxyURL("book-1", u, plain[strings.Index(plain, "&s=")+3:]); err != nil || decoded != "https://img.example.com/a.jpg" {
|
||||
t.Fatalf("签名校验失败: %q err=%v", decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
// HLS 判定:带 query 的 m3u8 与 Content-Type 不标准都要识别;普通图片不能被误判。
|
||||
func TestLooksLikeHLSURL(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://a.com/index.m3u8": true,
|
||||
"https://a.com/index.m3u8?token=xx": true,
|
||||
"https://a.com/live/index.M3U": true,
|
||||
"https://a.com/a.mp3": false,
|
||||
"https://a.com/m3u8/1": false,
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if got := looksLikeHLSURL(raw); got != want {
|
||||
t.Fatalf("looksLikeHLSURL(%q)=%v,期望 %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,10 @@ type BookSource struct {
|
||||
LoginCheckJS *string `json:"loginCheckJs"`
|
||||
JSLib *string `json:"jsLib"`
|
||||
BookSourceComment *string `json:"bookSourceComment"`
|
||||
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||
// CoverDecodeJs 封面 bytes 二次解密 JS(返回解密后的字节),
|
||||
// 对应 legado BookSource.coverDecodeJs。规则为空时封面零开销直通。
|
||||
CoverDecodeJs *string `json:"coverDecodeJs"`
|
||||
LastUpdateTime *int64 `json:"lastUpdateTime"`
|
||||
RespondTime *int64 `json:"respondTime"`
|
||||
Weight *int `json:"weight"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
@@ -151,7 +154,10 @@ type ContentRule struct {
|
||||
SourceRegex *string `json:"sourceRegex"`
|
||||
ReplaceRegex *string `json:"replaceRegex"`
|
||||
ImageStyle *string `json:"imageStyle"`
|
||||
PayAction *string `json:"payAction"`
|
||||
// ImageDecode 正文图片 bytes 二次解密 JS(返回解密后的字节),
|
||||
// 对应 legado ContentRule.imageDecode。
|
||||
ImageDecode *string `json:"imageDecode"`
|
||||
PayAction *string `json:"payAction"`
|
||||
}
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ list
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ type SchedulerService struct {
|
||||
|
||||
imagesPolicyProvider func() ImageCachePolicy
|
||||
|
||||
// readerContentCleaner 阅读正文缓存的清理钩子(由阅读模块注入)。
|
||||
readerContentCleaner func(context.Context)
|
||||
|
||||
segments *MediaSegmentService
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -86,6 +89,13 @@ func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
|
||||
return ImageCachePolicy{}
|
||||
}
|
||||
|
||||
// SetReaderContentCleaner 注入阅读正文缓存的清理钩子(TTL 过期 + 容量 LRU)。
|
||||
// 阅读模块自身持有缓存目录与索引(见 reader 包的 pruneReaderContentCache),
|
||||
// 调度器只负责按小时触发;未注入时不注册该任务。
|
||||
func (s *SchedulerService) SetReaderContentCleaner(fn func(context.Context)) {
|
||||
s.readerContentCleaner = fn
|
||||
}
|
||||
|
||||
// scheduledJob is one recurring task.
|
||||
type scheduledJob struct {
|
||||
name string
|
||||
@@ -148,6 +158,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
|
||||
run: s.jobCleanImageCache,
|
||||
},
|
||||
}
|
||||
// 阅读正文缓存清理只在注入钩子后注册(见 SetReaderContentCleaner)。
|
||||
if s.readerContentCleaner != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
name: "reader_content_cleanup",
|
||||
interval: 1 * time.Hour,
|
||||
run: s.jobCleanReaderContentCache,
|
||||
})
|
||||
}
|
||||
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
|
||||
if s.segments != nil {
|
||||
s.jobs = append(s.jobs, &scheduledJob{
|
||||
|
||||
@@ -177,6 +177,15 @@ func (s *SchedulerService) jobTelegramExpiryWarning(ctx context.Context) error {
|
||||
return s.expiryWatcher.RunOnce(ctx)
|
||||
}
|
||||
|
||||
// jobCleanReaderContentCache 触发阅读正文缓存的 TTL / 容量淘汰。
|
||||
func (s *SchedulerService) jobCleanReaderContentCache(ctx context.Context) error {
|
||||
if s.readerContentCleaner == nil {
|
||||
return nil
|
||||
}
|
||||
s.readerContentCleaner(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
|
||||
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||
if s.cacheDir == "" {
|
||||
|
||||
@@ -210,6 +210,10 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||
}
|
||||
return policy
|
||||
})
|
||||
// 阅读正文缓存清理:由阅读模块自己按 TTL + 容量 LRU 淘汰(见 reader_content_cache.go)。
|
||||
if b.c.Reader != nil {
|
||||
b.c.Scheduler.SetReaderContentCleaner(b.c.Reader.PruneContentCache)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *serviceContainerBuilder) initIdentityServices() {
|
||||
@@ -237,6 +241,15 @@ func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
|
||||
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthProvider(func(ctx context.Context, host string) (string, bool) {
|
||||
return b.c.EmbyRemote.RemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthRefresher(func(ctx context.Context, host string) (string, error) {
|
||||
return b.c.EmbyRemote.RefreshRemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
|
||||
@@ -122,6 +122,29 @@ export interface ReaderChapterContent {
|
||||
comments?: ReaderContentComment[]
|
||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
/** 该音轨是 HLS 播放列表(m3u8),前端据此走 hls.js 而不是原生 audio。 */
|
||||
hls?: boolean
|
||||
}
|
||||
|
||||
/** 批量取正文的单章结果(对应服务端 ContentBatchItem)。 */
|
||||
export interface ReaderContentBatchItem {
|
||||
chapter_index: number
|
||||
type?: 'text' | 'audio' | 'image'
|
||||
content?: string
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
hls?: boolean
|
||||
comments?: ReaderContentComment[]
|
||||
/** 由服务端持久缓存直接命中(本次未访问书源)。 */
|
||||
cached?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** 批量取正文的汇总(对应服务端 ContentBatchResult)。 */
|
||||
export interface ReaderContentBatchResult {
|
||||
items: ReaderContentBatchItem[]
|
||||
stats: { hit: number; miss: number; failed: number }
|
||||
}
|
||||
|
||||
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
|
||||
@@ -378,6 +401,16 @@ export const readerAPI = {
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// 批量取正文(窗口预取 / 离线缓存):单章失败不中断整批,命中持久缓存的章节零网络开销。
|
||||
bookContentBatch: (id: string, chapterIndexes: number[], signal?: AbortSignal) =>
|
||||
api
|
||||
.post<ReaderContentBatchResult>(
|
||||
`/reader/books/${id}/content-batch`,
|
||||
{ chapter_indexes: chapterIndexes },
|
||||
{ timeout: LONG_REQUEST_TIMEOUT, signal },
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
listReplaceRules: () =>
|
||||
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
|
||||
|
||||
@@ -41,10 +41,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
const card = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
@@ -120,26 +120,34 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact
|
||||
? 'truncate text-xs'
|
||||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||||
{media.video_codec && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
@@ -151,8 +159,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
<div className="group relative block h-full w-full">
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
@@ -166,8 +174,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
<div className="group relative block h-full">
|
||||
<Link to={href} state={linkState} className="block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -178,7 +186,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
<Link to={href} state={linkState} className="group block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
// 手机上单卡只有约 100px 宽,间距收到 gap-3 给标题和徽标多留一点宽度。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
'grid grid-cols-3 gap-3 sm:gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
@@ -105,7 +106,7 @@ export function VirtualMediaGrid({
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-3 sm:pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { decideDoubleTap } from './useDoubleTap.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`useDoubleTap: ${name}`)
|
||||
}
|
||||
|
||||
// 空闲时的第一下:进入等待窗口,单击不立刻触发(留给定时器)。
|
||||
const first = decideDoubleTap('none', 'tap')
|
||||
check('first tap opens a window', first.window === 'tap')
|
||||
check('first tap does not fire immediately', first.fire === null)
|
||||
|
||||
// 窗口里的第二下:判成双击并立刻触发(必须在事件里同步执行,见实现注释)。
|
||||
const second = decideDoubleTap('tap', 'tap')
|
||||
check('second tap fires double', second.fire === 'double')
|
||||
check('second tap closes the window', second.window === 'none')
|
||||
|
||||
// 菜单遮罩关菜单:只开窗口,不触发单击,也不影响状态语义。
|
||||
const armed = decideDoubleTap('none', 'arm')
|
||||
check('arm opens a window', armed.window === 'armed')
|
||||
check('arm never fires a single', armed.fire === null)
|
||||
|
||||
// 关掉菜单后紧接的第二下要算双击,否则菜单一开一关就永远进不了全屏。
|
||||
check('tap after arm fires double', decideDoubleTap('armed', 'tap').fire === 'double')
|
||||
|
||||
// 已经在双击窗口里再 arm:续期,窗口保持 armed。
|
||||
check('arm during window stays armed', decideDoubleTap('armed', 'arm').window === 'armed')
|
||||
check('arm during tap window stays armed', decideDoubleTap('tap', 'arm').window === 'armed')
|
||||
|
||||
console.log('useDoubleTap.test.ts ok')
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/** 两次点击间隔在这个毫秒数以内算一次双击。 */
|
||||
export const DOUBLE_TAP_MS = 280
|
||||
|
||||
/**
|
||||
* 双击窗口的状态。
|
||||
*
|
||||
* - `none` 空闲,没有待判定的点击
|
||||
* - `tap` 刚单击过一次,还等第二下;窗口期满仍没有第二下就触发单击
|
||||
* - `armed` 只开了双击窗口(菜单遮罩关掉菜单时用),期满什么也不触发
|
||||
*/
|
||||
export type DoubleTapWindow = 'none' | 'tap' | 'armed'
|
||||
|
||||
/** 一次动作立刻要触发的效果;null 表示只更新窗口状态、不立刻触发(留给定时器)。 */
|
||||
export type DoubleTapFire = 'single' | 'double' | null
|
||||
|
||||
export interface DoubleTapDecision {
|
||||
window: DoubleTapWindow
|
||||
fire: DoubleTapFire
|
||||
}
|
||||
|
||||
/**
|
||||
* 双击窗口的纯决策(不碰计时器,便于单测):给定当前窗口状态和这次动作,
|
||||
* 得出窗口进入什么状态、要不要立刻触发回调。
|
||||
*
|
||||
* `tap` = 用户点了一下中间区域;`arm` = 只开窗口不算点击(关菜单时调用)。
|
||||
*/
|
||||
export function decideDoubleTap(window: DoubleTapWindow, action: 'tap' | 'arm'): DoubleTapDecision {
|
||||
if (action === 'arm') {
|
||||
// 只开窗口:已在窗口里就续期,否则新开一个 armed 窗口,永远不触发单击。
|
||||
return { window: 'armed', fire: null }
|
||||
}
|
||||
// 已经有窗口(无论是单击待定的 tap 还是刚 arm 的)→ 第二下到达,判成双击。
|
||||
if (window !== 'none') return { window: 'none', fire: 'double' }
|
||||
// 第一下:进入 tap 窗口等第二下,单击由窗口期满的定时器触发。
|
||||
return { window: 'tap', fire: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* 中间区域的「单击 / 双击」识别(手机上用来区分呼出菜单和进出全屏)。
|
||||
*
|
||||
* 中间区域原先单击就是呼出菜单,现在双击还要切全屏,两个动作落在同一块屏幕上,
|
||||
* 就得先等一小会儿看有没有第二下:第一下把窗口置为 `tap`,第二下在窗口内到达就
|
||||
* 取消单击、改判双击。**双击回调必须在第二次点击的事件里同步执行**——浏览器只认
|
||||
* 用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。
|
||||
*
|
||||
* 返回三个入口:
|
||||
* - `tap()`:分区点击。`enabled` 为假(桌面端,或 iOS 这种不支持元素全屏的浏览器)
|
||||
* 时不做任何延迟,单击立刻生效,端上呼出菜单的手感与改动前完全一致。
|
||||
* - `arm()`:只开一个双击窗口、不排单击。菜单打开时整屏遮罩会先吃掉那次点击
|
||||
* (见 ReaderViewPage 的关闭遮罩),遮罩把菜单关掉的同时调它,紧跟着的第二下
|
||||
* 才能落进窗口被判成双击,而不是又当成单击把菜单重新打开。
|
||||
* - `cancel()`:丢掉待判定的点击。用户点了中间又立刻去翻页时,别让那次单击
|
||||
* 在窗口期满时把菜单弹出来。
|
||||
*/
|
||||
export function useDoubleTap({
|
||||
onDoubleTap,
|
||||
onSingleTap,
|
||||
enabled,
|
||||
delay = DOUBLE_TAP_MS,
|
||||
}: {
|
||||
onDoubleTap: () => void
|
||||
onSingleTap: () => void
|
||||
enabled: boolean
|
||||
delay?: number
|
||||
}): { tap: () => void; arm: () => void; cancel: () => void } {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const windowRef = useRef<DoubleTapWindow>('none')
|
||||
// 回调每次渲染都可能是新闭包,用 ref 兜住,免得已排队的定时器跟着旧闭包一起失效。
|
||||
const doubleRef = useRef(onDoubleTap)
|
||||
const singleRef = useRef(onSingleTap)
|
||||
useEffect(() => {
|
||||
doubleRef.current = onDoubleTap
|
||||
singleRef.current = onSingleTap
|
||||
})
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 取消待判定的点击并回到空闲。 */
|
||||
const cancel = useCallback(() => {
|
||||
clear()
|
||||
windowRef.current = 'none'
|
||||
}, [clear])
|
||||
|
||||
/** 开一个双击窗口:期满后,tap 窗口触发单击,armed 窗口什么也不做。 */
|
||||
const startWindow = useCallback(
|
||||
(kind: DoubleTapWindow) => {
|
||||
clear()
|
||||
windowRef.current = kind
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const wasTap = windowRef.current === 'tap'
|
||||
windowRef.current = 'none'
|
||||
if (wasTap) singleRef.current()
|
||||
}, delay)
|
||||
},
|
||||
[clear, delay],
|
||||
)
|
||||
|
||||
// 卸载时清掉待执行的单击,避免离开页面后还蹦一下。
|
||||
useEffect(() => cancel, [cancel])
|
||||
// 双击被关掉(如切到桌面布局)时,丢弃已排队的点击。
|
||||
useEffect(() => {
|
||||
if (!enabled) cancel()
|
||||
}, [enabled, cancel])
|
||||
|
||||
const tap = useCallback(() => {
|
||||
if (!enabled) {
|
||||
singleRef.current()
|
||||
return
|
||||
}
|
||||
const decision = decideDoubleTap(windowRef.current, 'tap')
|
||||
if (decision.fire === 'double') {
|
||||
// 第二下:取消待定的单击,改判双击(同步执行,保住用户手势上下文)。
|
||||
cancel()
|
||||
doubleRef.current()
|
||||
return
|
||||
}
|
||||
startWindow(decision.window)
|
||||
}, [enabled, cancel, startWindow])
|
||||
|
||||
const arm = useCallback(() => {
|
||||
if (!enabled) return
|
||||
startWindow(decideDoubleTap(windowRef.current, 'arm').window)
|
||||
}, [enabled, startWindow])
|
||||
|
||||
return { tap, arm, cancel }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
|
||||
|
||||
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
|
||||
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
||||
//
|
||||
// 判定规则(和纵向滚动共存的关键):
|
||||
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
|
||||
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
|
||||
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
|
||||
// 短距离当点击处理,交给下面的分区点击。
|
||||
//
|
||||
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
|
||||
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
|
||||
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
|
||||
// (慢慢拖过去也是拖动)。
|
||||
//
|
||||
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
||||
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
||||
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
||||
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
||||
|
||||
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
|
||||
const DIRECTION_SLOP = 12
|
||||
|
||||
interface HorizontalSwipeOptions {
|
||||
/** 从左往右滑(手指向右移)。 */
|
||||
onSwipeRight: () => void
|
||||
/** 从右往左滑(手指向左移)。 */
|
||||
onSwipeLeft: () => void
|
||||
/** 触发翻页所需的最小水平位移(px)。 */
|
||||
minDistance?: number
|
||||
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
||||
dominance?: number
|
||||
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
|
||||
maxDuration?: number
|
||||
/**
|
||||
* 跟手拖拽开始(方向判定通过、第一次 onDrag 之前)调一次。
|
||||
* 宿主在这里记下跟手层当前的实际位移:上一次翻页的归位动画可能还没滑完,
|
||||
* 新手势要接着那点残余位移继续,否则中途接管会把残余一笔抹掉,画面跳一格。
|
||||
*/
|
||||
onDragStart?: () => void
|
||||
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
|
||||
onDrag?: (dx: number) => void
|
||||
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
|
||||
onDragEnd?: (dx: number) => void
|
||||
}
|
||||
|
||||
interface HorizontalSwipeHandlers {
|
||||
onTouchStart: (e: ReactTouchEvent) => void
|
||||
onTouchMove: (e: ReactTouchEvent) => void
|
||||
onTouchEnd: (e: ReactTouchEvent) => void
|
||||
onTouchCancel: () => void
|
||||
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
||||
consumeSwipe: () => boolean
|
||||
}
|
||||
|
||||
export function useHorizontalSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
minDistance = 45,
|
||||
dominance = 1.2,
|
||||
maxDuration = 800,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
||||
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
||||
const consumedRef = useRef(false)
|
||||
/** 本次手势是否已判定为横滑并在跟手。 */
|
||||
const draggingRef = useRef(false)
|
||||
const lastDxRef = useRef(0)
|
||||
|
||||
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
|
||||
consumedRef.current = false
|
||||
draggingRef.current = false
|
||||
lastDxRef.current = 0
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
const t = e.touches[0]
|
||||
if (!start || !t) return
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (!draggingRef.current) {
|
||||
if (!onDrag) return
|
||||
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
|
||||
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) {
|
||||
startRef.current = null
|
||||
return
|
||||
}
|
||||
draggingRef.current = true
|
||||
onDragStart?.()
|
||||
}
|
||||
lastDxRef.current = dx
|
||||
onDrag?.(dx)
|
||||
},
|
||||
[onDragStart, onDrag, dominance],
|
||||
)
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
startRef.current = null
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false
|
||||
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
|
||||
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
|
||||
consumedRef.current = true
|
||||
const t = e.changedTouches[0]
|
||||
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
|
||||
const dx = start && t ? t.clientX - start.x : lastDxRef.current
|
||||
onDragEnd?.(dx)
|
||||
return
|
||||
}
|
||||
const t = e.changedTouches[0]
|
||||
if (!start || !t) return
|
||||
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (Date.now() - start.t > maxDuration) return
|
||||
if (Math.abs(dx) < minDistance) return
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) return
|
||||
|
||||
consumedRef.current = true
|
||||
if (dx > 0) onSwipeRight()
|
||||
else onSwipeLeft()
|
||||
},
|
||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
|
||||
)
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
startRef.current = null
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
|
||||
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
|
||||
onDragEnd?.(0)
|
||||
}, [onDragEnd])
|
||||
|
||||
const consumeSwipe = useCallback(() => {
|
||||
if (!consumedRef.current) return false
|
||||
consumedRef.current = false
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// 按设备能力判断「手机/平板」:没有真正的鼠标悬停能力的就是触摸设备。
|
||||
// 应用里已有同样的做法(PlayerControls / Vr360Stage 的 isHoverlessDevice),
|
||||
// 这里做成 hook:媒体查询在字体缩放、外接鼠标插拔、chrome 设备模拟时会变化,
|
||||
// 跟随 change 事件回写,比渲染时读一次更稳。
|
||||
const HOVERLESS_QUERY = '(hover: none)'
|
||||
|
||||
function queryHoverless(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
||||
return window.matchMedia(HOVERLESS_QUERY).matches
|
||||
}
|
||||
|
||||
/** 当前设备是否是触摸设备(手机/平板)。桌面端(带鼠标)返回 false。 */
|
||||
export function useIsTouchDevice(): boolean {
|
||||
const [touch, setTouch] = useState(queryHoverless)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||
const media = window.matchMedia(HOVERLESS_QUERY)
|
||||
const onChange = () => setTouch(media.matches)
|
||||
onChange()
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
return touch
|
||||
}
|
||||
+106
-11
@@ -81,6 +81,10 @@ type PlaybackProgressSession = {
|
||||
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
||||
const SKIP_NOTICE_MS = 6000
|
||||
|
||||
// 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。
|
||||
const CLOUD_HLS_RELOAD_MAX = 3
|
||||
const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -185,6 +189,17 @@ export function PlayerPage() {
|
||||
const cloudPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const cloudRetryRef = useRef(5)
|
||||
const pendingSeekRef = useRef<number | null>(null)
|
||||
// 云 HLS 会话重建计数(时间戳)。115 云转码的会话只存在服务端内存里:服务
|
||||
// 重新部署、会话过期(30 分钟无活动)、上游地址失效都会让播放中途拿到
|
||||
// 404/410。这种情况下重新拉一次 master.m3u8 建个新会话即可续播,没必要直接
|
||||
// 掉到本地转码(这台机器本地转码只有 0.6x,等于播放报废)。窗口内限制次数,
|
||||
// 避免上游真的坏了时无限重连。
|
||||
const cloudReloadsRef = useRef<number[]>([])
|
||||
const [cloudReloadNonce, setCloudReloadNonce] = useState(0)
|
||||
// 切回直连/原画时把当前位置带过去:HLS 卸载会把 video.currentTime 归零,
|
||||
// 必须在切换前先记下来,等直连 loadedmetadata 后再跳。
|
||||
const pendingDirectStartRef = useRef(0)
|
||||
const hlsSourceRef = useRef<'cloud' | 'local'>('local')
|
||||
|
||||
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||
@@ -488,6 +503,8 @@ export function PlayerPage() {
|
||||
setCloudWaitMessage('')
|
||||
setCloudWaitStartedAt(0)
|
||||
pendingSeekRef.current = null
|
||||
cloudReloadsRef.current = []
|
||||
pendingDirectStartRef.current = 0
|
||||
if (cloudPollTimerRef.current) {
|
||||
clearTimeout(cloudPollTimerRef.current)
|
||||
cloudPollTimerRef.current = null
|
||||
@@ -953,6 +970,38 @@ export function PlayerPage() {
|
||||
[playbackInfo, setPlaybackMode],
|
||||
)
|
||||
|
||||
/**
|
||||
* 115 云 HLS 中途断流后重建会话。
|
||||
*
|
||||
* 115 的云端转码会话只存在服务端内存里:服务重新部署、会话到期(30 分钟无活动)、
|
||||
* 上游分片地址失效,都会让正在播放的客户端在后续分片上拿到 404/410。重新请求一次
|
||||
* master.m3u8 就会建出新会话,从当前位置继续播即可——比直接退回本地转码好得多
|
||||
* (低配宿主机本地转码往往追不上播放速度,等于不能看)。
|
||||
*
|
||||
* 窗口内超过次数上限返回 false,由调用方走本地转码兜底,避免上游真的坏了时无限重连。
|
||||
*/
|
||||
const reloadCloudHls = useCallback(
|
||||
(position: number): boolean => {
|
||||
const now = Date.now()
|
||||
const recent = cloudReloadsRef.current.filter((at) => now - at < CLOUD_HLS_RELOAD_WINDOW_MS)
|
||||
if (recent.length >= CLOUD_HLS_RELOAD_MAX) {
|
||||
cloudReloadsRef.current = recent
|
||||
return false
|
||||
}
|
||||
recent.push(now)
|
||||
cloudReloadsRef.current = recent
|
||||
// 云 HLS 是整条时间轴,起播后按绝对位置跳一下,别让用户回到片头。
|
||||
pendingSeekRef.current = position > 2 ? position : null
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
setHlsSource('cloud')
|
||||
setPlaybackMode('hls')
|
||||
setCloudReloadNonce((v) => v + 1)
|
||||
return true
|
||||
},
|
||||
[setPlaybackMode],
|
||||
)
|
||||
|
||||
const startCloudTranscode = useCallback(
|
||||
async (definition: number) => {
|
||||
if (!mediaId) return
|
||||
@@ -1126,7 +1175,10 @@ export function PlayerPage() {
|
||||
// hosts and remote STRM sources regularly need more than hls.js's
|
||||
// 10s default, which otherwise aborts a healthy transcode.
|
||||
manifestLoadingTimeOut: 60_000,
|
||||
manifestLoadingMaxRetry: 1,
|
||||
// 115 云 HLS 的 master.m3u8 每次都要现调 115 接口(实测 0.5–1.3s),
|
||||
// 还可能撞上 409「正在转码」。只重试 1 次的话,一次抖动就判死刑,
|
||||
// 直接掉到本地转码。
|
||||
manifestLoadingMaxRetry: 4,
|
||||
})
|
||||
try {
|
||||
video.currentTime = 0
|
||||
@@ -1160,6 +1212,11 @@ export function PlayerPage() {
|
||||
if (data.fatal) {
|
||||
if (hlsSource === 'cloud') {
|
||||
const position = ref.current?.currentTime || 0
|
||||
// 先尝试重建云会话续播;只有连着失败到上限才退回本地转码。
|
||||
if (reloadCloudHls(position)) {
|
||||
toast('115 云端播放中断,正在重新连接…', { duration: 2200 })
|
||||
return
|
||||
}
|
||||
setHlsUnavailable(false)
|
||||
switchToLocalHLS(position)
|
||||
toast.error('115 云端播放失败,切换本地转码')
|
||||
@@ -1211,12 +1268,24 @@ export function PlayerPage() {
|
||||
if (video.src !== absoluteURL) {
|
||||
directRetryRef.current = false
|
||||
clearFallbackTimer()
|
||||
// 进出 VR 会切换播放源地址,这里保住当前播放位置。
|
||||
const resumeAt = video.currentTime > 2 ? video.currentTime : 0
|
||||
// 保住当前位置。两个来源:显式切回直连/原画时提前记下的目标位置
|
||||
// (HLS 卸载会把 video.currentTime 归零,所以不能只读 video.currentTime),
|
||||
// 以及 VR 进出时切换播放源地址的情形。
|
||||
const resumeAt = Math.max(
|
||||
pendingDirectStartRef.current,
|
||||
video.currentTime > 2 ? video.currentTime : 0,
|
||||
)
|
||||
pendingDirectStartRef.current = 0
|
||||
if (resumeAt > 0) {
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
// STRM/115 直链要等 seekable 覆盖目标位置再写 currentTime(否则会被
|
||||
// 钳回 0),并顺带打开误报宽限期,避免这次跳转被当成播放失败又弹回 HLS。
|
||||
if (isStrmMedia(currentMedia)) {
|
||||
seekStrmDirectTo(resumeAt)
|
||||
return
|
||||
}
|
||||
try {
|
||||
video.currentTime = resumeAt
|
||||
} catch {
|
||||
@@ -1243,6 +1312,7 @@ export function PlayerPage() {
|
||||
}, [
|
||||
activeBurnedSubtitleStream,
|
||||
clearFallbackTimer,
|
||||
cloudReloadNonce,
|
||||
cloudWaiting,
|
||||
hlsSource,
|
||||
hlsUnavailable,
|
||||
@@ -1250,6 +1320,8 @@ export function PlayerPage() {
|
||||
mediaId,
|
||||
mode,
|
||||
playbackProvider,
|
||||
reloadCloudHls,
|
||||
seekStrmDirectTo,
|
||||
selectedQuality,
|
||||
setPlaybackMode,
|
||||
switchToLocalHLS,
|
||||
@@ -1398,8 +1470,9 @@ export function PlayerPage() {
|
||||
const modeRef = useRef(mode)
|
||||
useEffect(() => {
|
||||
hlsStartSecRef.current = hlsStartSec
|
||||
hlsSourceRef.current = hlsSource
|
||||
modeRef.current = mode
|
||||
}, [hlsStartSec, mode])
|
||||
}, [hlsStartSec, hlsSource, mode])
|
||||
|
||||
// Persist resume position every 10 seconds while playing, and immediately upon
|
||||
// pause/page hide/unmount. Bind after mediaId is available because
|
||||
@@ -1412,16 +1485,21 @@ export function PlayerPage() {
|
||||
lastSentRef.current = 0
|
||||
}
|
||||
|
||||
// 本地 HLS 的播放列表 t=0 对应 hlsStartSec,云 HLS 的播放列表本身就是整条
|
||||
// 时间轴(起点恒为 0)。以前两种模式都叠加 hlsStartSec:只要本地转码残留过
|
||||
// 一个非 0 起点再切到云端,上报的进度和总时长就会被整体抬高(实测把 7624s
|
||||
// 的片子记成 8346s 并直接标成「已看完」)。
|
||||
const timelineOffsetSec = () =>
|
||||
modeRef.current === 'hls' && hlsSourceRef.current === 'local' ? hlsStartSecRef.current : 0
|
||||
|
||||
const absolutePositionMs = () => {
|
||||
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const absolute = currentStartSec + (video.currentTime || 0)
|
||||
const absolute = timelineOffsetSec() + (video.currentTime || 0)
|
||||
return Number.isFinite(absolute) ? Math.max(0, Math.floor(absolute * 1000)) : 0
|
||||
}
|
||||
const absoluteDurationMs = () => {
|
||||
const currentStartSec = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
|
||||
const mediaDur = mediaRef.current?.duration_sec || 0
|
||||
const streamDur = Number.isFinite(video.duration) ? video.duration : 0
|
||||
const totalSec = Math.max(mediaDur, currentStartSec + streamDur)
|
||||
const totalSec = Math.max(mediaDur, timelineOffsetSec() + streamDur)
|
||||
return Number.isFinite(totalSec) && totalSec > 0 ? Math.floor(totalSec * 1000) : 0
|
||||
}
|
||||
const send = (keepalive: boolean) => {
|
||||
@@ -1782,16 +1860,27 @@ export function PlayerPage() {
|
||||
toast('该媒体为直连播放,无需且不支持转码')
|
||||
return
|
||||
}
|
||||
const video = ref.current
|
||||
// HLS 卸载会把 video.currentTime 归零,所以先按当前模式换算成绝对秒数记下来。
|
||||
const position = video
|
||||
? mode === 'hls' && hlsSource === 'local'
|
||||
? hlsStartSec + video.currentTime
|
||||
: video.currentTime
|
||||
: 0
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
if (next === 'hls') {
|
||||
// 带着当前位置切过去:云 HLS 跳一下、本地 HLS 从该点重开转码,都不从头播。
|
||||
enterHlsPlayback(ref.current?.currentTime || 0)
|
||||
enterHlsPlayback(position)
|
||||
} else {
|
||||
setCloudWaiting(false)
|
||||
// 直连要等 loadedmetadata 再跳(见播放方式接线 effect),这里先把目标位置存下来。
|
||||
pendingDirectStartRef.current = position
|
||||
}
|
||||
setPlaybackMode(next)
|
||||
}, [
|
||||
enterHlsPlayback,
|
||||
hlsSource,
|
||||
hlsStartSec,
|
||||
isDirectStream,
|
||||
mode,
|
||||
setPlaybackMode,
|
||||
@@ -1989,16 +2078,22 @@ export function PlayerPage() {
|
||||
const selectPlaybackQuality = useCallback(
|
||||
(quality: PlaybackQuality) => {
|
||||
const video = ref.current
|
||||
const position = video?.currentTime || 0
|
||||
const localTime = video?.currentTime || 0
|
||||
// 云 HLS 的 currentTime 本来就在绝对时间轴上;本地 HLS 的 t=0 对应 hlsStartSec。
|
||||
const position = mode === 'hls' && hlsSource === 'local' ? hlsStartSec + localTime : localTime
|
||||
if (quality.source === 'original') {
|
||||
setCloudWaiting(false)
|
||||
setHlsSource('local')
|
||||
// 切回原始文件直连:位置由直连分支的 loadedmetadata 跳转负责带过去。
|
||||
pendingDirectStartRef.current = position
|
||||
setPlaybackMode('direct')
|
||||
return
|
||||
}
|
||||
if (quality.source === 'cloud') {
|
||||
setSelectedQuality(quality.id)
|
||||
setHlsSource('cloud')
|
||||
// 用户手动选了云端档位:这是一次明确的重新尝试,重置自动重连的次数窗口。
|
||||
cloudReloadsRef.current = []
|
||||
if (quality.available) {
|
||||
setCloudWaiting(false)
|
||||
setCloudWaitMessage('')
|
||||
@@ -2025,7 +2120,7 @@ export function PlayerPage() {
|
||||
if (position > 2) setHlsStartSec(position)
|
||||
setPlaybackMode('hls')
|
||||
},
|
||||
[setPlaybackMode, startCloudTranscode],
|
||||
[hlsSource, hlsStartSec, mode, setPlaybackMode, startCloudTranscode],
|
||||
)
|
||||
|
||||
const changeSubtitleChineseMode = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
@@ -27,6 +27,13 @@ interface ReaderComicProps {
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
/**
|
||||
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
|
||||
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
|
||||
*/
|
||||
draggable?: boolean
|
||||
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
|
||||
stageRef?: RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
function ComicImage({
|
||||
@@ -36,6 +43,7 @@ function ComicImage({
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
eager = false,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
@@ -47,6 +55,10 @@ function ComicImage({
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
|
||||
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
|
||||
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
|
||||
eager?: boolean
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
@@ -82,7 +94,7 @@ function ComicImage({
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
alt=""
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
@@ -114,7 +126,7 @@ function ComicImage({
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default', draggable = false, stageRef }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
@@ -174,11 +186,39 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
||||
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||
const paired = idxs.length > 1
|
||||
|
||||
if (!draggable) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 跟手拖拽:当前页左右各摆一页,整条舞台由外层直接平移(stageRef),
|
||||
// 于是手指横滑时左右两页就在屏幕边上,滑到一半松手能接着滑完。
|
||||
// 外面再套一层 overflow-hidden:桌面端正文列限宽 900px 居中,两侧那两页要是
|
||||
// 溢出到窗口里会在留白处露出来。
|
||||
//
|
||||
// 单元格按图片序号做 key、位置用 left 百分比铺开,而不是固定三个「上一页/当前/下一页」
|
||||
// 槽位换内容:翻页时变的只是 left 偏移,<img> 的 src 不变,浏览器不会重新解码、
|
||||
// 也不会先闪一下转圈。两侧那两页同时充当下一页/上一页的预取。
|
||||
const cells = [page - 1, page, page + 1].filter((i) => i >= 0 && i < images.length)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div ref={stageRef} className="relative h-full w-full">
|
||||
{cells.map((i) => (
|
||||
<div
|
||||
key={images[i]}
|
||||
className="absolute inset-y-0 flex items-center justify-center"
|
||||
style={{ left: `${(i - page) * 100}%`, width: '100%' }}
|
||||
>
|
||||
<ComicImage src={images[i]} theme={theme} fit eager={i !== page} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -212,8 +252,10 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width
|
||||
if (x < 0.3) {
|
||||
onZone('left')
|
||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else if (x > 0.7) {
|
||||
onZone('right')
|
||||
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||
} else {
|
||||
onZone('center')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,12 @@ export interface LoadChapterInput {
|
||||
chapterIndex: number
|
||||
/** 当前章的音轨地址(已签名)。 */
|
||||
track: string
|
||||
/**
|
||||
* 音轨是否为 HLS 播放列表(服务端下发)。
|
||||
* 代理地址是 base64 的 /api/reader/media?u=...,前端无法再从后缀判断格式,
|
||||
* 必须用这个标志决定走 hls.js 还是原生 <audio>。
|
||||
*/
|
||||
hls?: boolean
|
||||
transcoding: boolean
|
||||
openCredits: number
|
||||
closeCredits: number
|
||||
@@ -64,6 +70,14 @@ class ReaderAudioEngine {
|
||||
private detaching = false
|
||||
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
|
||||
private loadToken = 0
|
||||
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
|
||||
private prefetched = new Map<number, { track: string; transcoding: boolean; hls: boolean }>()
|
||||
/** 预取在途的章序号,避免同一章重复请求。 */
|
||||
private prefetching = new Set<number>()
|
||||
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
|
||||
private warmer: HTMLAudioElement | null = null
|
||||
/** warmer 当前装载的音轨,避免重复赋值。 */
|
||||
private warmedTrack = ''
|
||||
|
||||
constructor() {
|
||||
const audio = new Audio()
|
||||
@@ -93,7 +107,13 @@ class ReaderAudioEngine {
|
||||
}
|
||||
// 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文)
|
||||
this.commitProgress()
|
||||
// 换书:预取的音轨属于上一本,全部作废
|
||||
if (s.bookId !== input.bookId) this.clearPrefetch()
|
||||
this.applyChapter(input)
|
||||
}
|
||||
|
||||
/** 真正把一章装上播放器(同步部分):预取命中的续播也走这里。 */
|
||||
private applyChapter(input: LoadChapterInput): void {
|
||||
const chapterTitle = input.chapters[input.chapterIndex]?.title ?? ''
|
||||
useReaderAudioStore.setState({
|
||||
bookId: input.bookId,
|
||||
@@ -127,7 +147,10 @@ class ReaderAudioEngine {
|
||||
if (preset > 0) this.applyTimer(preset)
|
||||
}
|
||||
|
||||
void this.attachSource(input.track)
|
||||
void this.attachSource(input.track, input.hls === true)
|
||||
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null) this.prefetchChapter(nextIndex)
|
||||
}
|
||||
|
||||
play(): void {
|
||||
@@ -222,6 +245,7 @@ class ReaderAudioEngine {
|
||||
stop(): void {
|
||||
this.commitProgress()
|
||||
this.detachSource()
|
||||
this.clearPrefetch()
|
||||
this.timerDeadline = 0
|
||||
this.pausedAt = 0
|
||||
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
|
||||
@@ -240,15 +264,29 @@ class ReaderAudioEngine {
|
||||
|
||||
// ─── 音源装载 ─────────────────────────────────────────────────────────
|
||||
|
||||
private async attachSource(src: string): Promise<void> {
|
||||
/**
|
||||
* 装上音源。isHLS 由服务端判定(见 LoadChapterInput.hls)——
|
||||
* 代理地址不含 .m3u8 后缀,前端不能再用地址猜格式。
|
||||
*/
|
||||
private async attachSource(src: string, isHLS: boolean): Promise<void> {
|
||||
const token = ++this.loadToken
|
||||
this.detachSource()
|
||||
this.setSessionMetadata(src)
|
||||
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
|
||||
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
|
||||
// 「无用户手势」拦下——表现正是切章卡住、解锁后必须手动点播放。直接覆盖 src,
|
||||
// 元素始终持有媒体,会话才接得上。
|
||||
this.hls?.destroy()
|
||||
this.hls = null
|
||||
if (!src) {
|
||||
this.audio.removeAttribute('src')
|
||||
this.audio.load()
|
||||
this.setSessionMetadata(null)
|
||||
this.fail('本章没有可播放的音频')
|
||||
return
|
||||
}
|
||||
if (src.includes('.m3u8')) {
|
||||
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
|
||||
if (!this.audio.paused) this.audio.pause()
|
||||
this.setSessionMetadata(src)
|
||||
if (isHLS) {
|
||||
const mod = await import('hls.js')
|
||||
// 动态 import 期间可能已经切到别的章:丢弃这次结果
|
||||
if (token !== this.loadToken) return
|
||||
@@ -270,6 +308,7 @@ class ReaderAudioEngine {
|
||||
this.audio.src = src
|
||||
}
|
||||
|
||||
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
|
||||
private detachSource(): void {
|
||||
this.detaching = true
|
||||
this.hls?.destroy()
|
||||
@@ -279,9 +318,118 @@ class ReaderAudioEngine {
|
||||
this.detaching = false
|
||||
}
|
||||
|
||||
// ─── 预取与续播 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 提前解析某章的音轨并预热字节。息屏时 ended 与 play() 之间不能有网络空档,
|
||||
* 否则系统收走音频会话、标签页被冻结,浏览器就拒绝自动播放了。
|
||||
*/
|
||||
private prefetchChapter(index: number): void {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
if (this.prefetched.has(index) || this.prefetching.has(index)) return
|
||||
// 反复跳章时旧的预取会变成垃圾,做个上限兜底
|
||||
if (this.prefetched.size > 32) this.prefetched.clear()
|
||||
this.prefetching.add(index)
|
||||
const forBook = bookId
|
||||
readerAPI
|
||||
.bookContent(bookId, index)
|
||||
.then((ct) => {
|
||||
// 期间换了书:结果作废
|
||||
if (useReaderAudioStore.getState().bookId !== forBook) return
|
||||
const track = ct.tracks?.[0]
|
||||
if (ct.type !== 'audio' || !track) return
|
||||
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false, hls: ct.hls ?? false })
|
||||
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
|
||||
// 避免用户中途停下时白下整章的流量。
|
||||
if (ct.transcoding) this.warmNextBytes(track, ct.hls ?? false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => this.prefetching.delete(index))
|
||||
}
|
||||
|
||||
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
|
||||
private warmNextBytes(track: string, isHLS: boolean): void {
|
||||
// HLS 交给 hls.js 按需拉流,二次下载没有意义
|
||||
if (!track || isHLS || this.warmedTrack === track) return
|
||||
this.warmedTrack = track
|
||||
if (!this.warmer) {
|
||||
const a = new Audio()
|
||||
a.preload = 'auto'
|
||||
this.warmer = a
|
||||
}
|
||||
this.warmer.src = track
|
||||
}
|
||||
|
||||
/** 本章快播完时把下一章的字节预热起来(30 秒足够开头缓冲)。 */
|
||||
private maybeWarmNext(): void {
|
||||
const audio = this.audio
|
||||
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return
|
||||
if (audio.duration - audio.currentTime > 30) return
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex === null) return
|
||||
const hit = this.prefetched.get(nextIndex)
|
||||
if (hit) this.warmNextBytes(hit.track, hit.hls)
|
||||
}
|
||||
|
||||
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
|
||||
private playPrefetched(index: number): boolean {
|
||||
const hit = this.prefetched.get(index)
|
||||
if (!hit) return false
|
||||
this.prefetched.delete(index)
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.applyChapter({
|
||||
bookId: s.bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters: s.chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
hls: hit.hls,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos: 0,
|
||||
})
|
||||
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
|
||||
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
|
||||
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
|
||||
if (!hit.hls) this.play()
|
||||
return true
|
||||
}
|
||||
|
||||
private clearPrefetch(): void {
|
||||
this.prefetched.clear()
|
||||
this.prefetching.clear()
|
||||
this.warmedTrack = ''
|
||||
if (this.warmer) {
|
||||
this.warmer.removeAttribute('src')
|
||||
this.warmer.load()
|
||||
}
|
||||
}
|
||||
|
||||
private async goToChapter(index: number, initialPos: number): Promise<void> {
|
||||
const { bookId, chapters } = useReaderAudioStore.getState()
|
||||
if (!bookId || index < 0 || index >= chapters.length || chapters[index].is_volume) return
|
||||
// 命中预取:直接装,不等网络(片尾自动续播大多走这里)
|
||||
const hit = this.prefetched.get(index)
|
||||
if (hit) {
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
const s = useReaderAudioStore.getState()
|
||||
this.loadChapter({
|
||||
bookId,
|
||||
bookName: s.bookName,
|
||||
cover: s.cover,
|
||||
chapters,
|
||||
chapterIndex: index,
|
||||
track: hit.track,
|
||||
transcoding: hit.transcoding,
|
||||
openCredits: s.openCredits,
|
||||
closeCredits: s.closeCredits,
|
||||
initialPos,
|
||||
})
|
||||
return
|
||||
}
|
||||
useReaderAudioStore.setState({ status: 'loading', error: '' })
|
||||
try {
|
||||
const ct = await readerAPI.bookContent(bookId, index)
|
||||
@@ -344,6 +492,10 @@ class ReaderAudioEngine {
|
||||
this.lastTime = Number.isFinite(audio.duration) ? audio.duration : this.lastTime
|
||||
this.commitProgress()
|
||||
if (useReaderAudioStore.getState().hasNext) {
|
||||
// 预取命中:在 ended 的同一次事件里同步接上下一章。息屏时这段「零空档」
|
||||
// 很关键——空太久系统会收走音频会话、冻结标签页,浏览器随后拒绝自动播放。
|
||||
const nextIndex = this.neighborChapterIndex(1)
|
||||
if (nextIndex !== null && this.playPrefetched(nextIndex)) return
|
||||
this.next()
|
||||
return
|
||||
}
|
||||
@@ -389,6 +541,7 @@ class ReaderAudioEngine {
|
||||
this.checkCreditsEnd()
|
||||
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
|
||||
this.checkTimer()
|
||||
this.maybeWarmNext()
|
||||
})
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
SCROLL_SAVE_THRESHOLD_PCT,
|
||||
formatScrollPct,
|
||||
parseLastScrollPct,
|
||||
shouldSaveScrollProgress,
|
||||
} from './readerScrollProgress.ts'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`readerScrollProgress: ${name}`)
|
||||
}
|
||||
|
||||
// 回归:dataset.last 缺失时 Number(undefined) 是 NaN,旧实现用 `?? -1` 接不住,
|
||||
// 比较恒为 false,滚动进度永远不保存。首次滚动必须触发。
|
||||
check('missing dataset triggers the first save', shouldSaveScrollProgress(0.1, undefined) === true)
|
||||
|
||||
// 空串同样要按「没有历史值」处理。
|
||||
check('empty dataset triggers save', shouldSaveScrollProgress(0.1, '') === true)
|
||||
|
||||
// 变化不足阈值不保存(避免每帧都上报)。
|
||||
check('small delta is throttled', shouldSaveScrollProgress(0.101, '0.1') === false)
|
||||
|
||||
// 达到阈值保存。
|
||||
check('threshold delta saves', shouldSaveScrollProgress(0.13, '0.1') === true)
|
||||
check('backward scroll saves', shouldSaveScrollProgress(0.07, '0.1') === true)
|
||||
|
||||
// 边界:正好等于阈值。
|
||||
const justAt = 0.1 + SCROLL_SAVE_THRESHOLD_PCT / 100
|
||||
check('exactly at threshold saves', shouldSaveScrollProgress(justAt, '0.1') === true)
|
||||
|
||||
// 非法输入不触发保存。
|
||||
check('non-finite current pct is ignored', shouldSaveScrollProgress(NaN, '0.1') === false)
|
||||
check('corrupt dataset value is treated as missing', shouldSaveScrollProgress(0.5, 'not-a-number') === true)
|
||||
|
||||
// 解析与格式化往返。
|
||||
check('parse keeps finite values', parseLastScrollPct('0.25') === 0.25)
|
||||
check('parse rejects garbage', Number.isNaN(parseLastScrollPct('x')))
|
||||
check('format round-trips', parseLastScrollPct(formatScrollPct(0.375)) === 0.375)
|
||||
|
||||
console.log('readerScrollProgress tests passed')
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 滚动阅读的进度保存节流。
|
||||
*
|
||||
* 只按滚动百分比的变化幅度决定是否上报,避免每帧都发保存请求。
|
||||
* 状态存在 DOM 的 dataset 上(滚动是热路径,不走 React state)。
|
||||
*/
|
||||
|
||||
/** 触发保存的最小百分比变化(2%)。 */
|
||||
export const SCROLL_SAVE_THRESHOLD_PCT = 2
|
||||
|
||||
/**
|
||||
* 读取上次保存的滚动百分比。
|
||||
*
|
||||
* 必须用 Number.isFinite 判断:dataset 缺失时 Number(undefined) 得到 NaN,
|
||||
* 而 NaN 与任何值比较都是 false,会导致首次滚动永远不保存(进度丢失)。
|
||||
*/
|
||||
export function parseLastScrollPct(raw: string | undefined): number {
|
||||
const n = Number.parseFloat(raw ?? '')
|
||||
return Number.isFinite(n) ? n : NaN
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前滚动百分比是否应该触发保存。
|
||||
*
|
||||
* @param currentPct 当前滚动百分比(0~1)
|
||||
* @param lastRaw 上次保存的原始值(dataset.last)
|
||||
*/
|
||||
export function shouldSaveScrollProgress(currentPct: number, lastRaw: string | undefined): boolean {
|
||||
if (!Number.isFinite(currentPct)) return false
|
||||
const last = parseLastScrollPct(lastRaw)
|
||||
if (!Number.isFinite(last)) return true
|
||||
return Math.abs(currentPct - last) * 100 >= SCROLL_SAVE_THRESHOLD_PCT
|
||||
}
|
||||
|
||||
/** 把百分比格式化成保存用的字符串。 */
|
||||
export function formatScrollPct(pct: number): string {
|
||||
return String(pct)
|
||||
}
|
||||
Vendored
+6
@@ -1,6 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
future: {
|
||||
// hover 样式只对真正能悬停的设备(鼠标/触控板)生效。触屏点击会让浏览器
|
||||
// 保留 :hover 状态,导致海报墙上的卡片被放大、上移、盖上「立即观影」遮罩,
|
||||
// 看起来像是整行错位、海报显示不全。
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user