Compare commits

..

18 Commits

Author SHA1 Message Date
truewhile b50cb7c31e 优化 2026-10-08 23:43:28 +08:00
truewhile 5f4b613bde 修复 115 云转码播放中途断流后直接掉到本地转码
115 云 HLS 会话只存在服务端内存里:服务重新部署、会话过期(30 分钟无活动)、
上游分片地址失效,都会让播放中的客户端在后续分片上拿到 404/410。原实现此时
直接退回本地转码,而低配宿主机本地转码追不上播放速度(实测 2 vCPU 只有
0.62x),用户看到的就是播放报废。

- 云 HLS 中途 fatal 时先重建会话续播(10 分钟内最多 3 次),仍失败才退回本地
- manifestLoadingMaxRetry 1 -> 4:master.m3u8 每次都要现调 115 接口,一次抖动
  不该直接判死刑
- 播放列表重写改用上游地址哈希做 key:原来用自增序号,每次重写 entries 就膨胀
  一轮、分片编号漂移(实测 762 段列表重写几次后 key 涨到 e2288..e3049)
- 会话条目数加上限,异常播放列表不会无限占用内存
- 客户端断开(seek / 切清晰度取消在途请求)不再记成 502,改记 499 与 nginx
  对齐;会话失效返回带 code 的 404/410,便于前端判断需要重建会话
- 修正进度/时长上报:云 HLS 时间轴起点恒为 0,不应再叠加 hlsStartSec(历史上
  把 7624s 的片子记成 8346s 并标成「已看完」)
- 切回直连 / 原画时保住播放位置:HLS 卸载会把 currentTime 归零,必须在切换前
  先记下目标位置
2026-10-08 22:52:31 +08:00
truewhile 67ef9c95be 优化 2026-10-08 22:18:16 +08:00
truewhile 2bf1b27e23 优化翻页 2026-10-08 20:53:39 +08:00
truewhile 48a1c36a7a 优化 2026-10-08 20:13:46 +08:00
truewhile d7665e4ef0 优化 2026-10-08 17:42:07 +08:00
truewhile 99916c0b97 优化 2026-10-08 13:48:06 +08:00
truewhile 6adc3cb3b2 删除文件 2026-10-08 11:27:28 +08:00
truewhile 2b2dc697ce bug处理 2026-10-08 11:13:10 +08:00
truewhile 8bfdd75d47 优化 2026-10-04 22:17:19 +08:00
truewhile 1a3248f4bb 优化 2026-10-04 21:31:09 +08:00
truewhile 0be7f8b97e 优化 2026-10-04 17:50:26 +08:00
truewhile 6984158f24 修复段评往下滑一直闪:评论接口分页改游标,页面还在按 page 翻页
上游评论页按 page=N 翻页,但列表接口 /para_review 早已改成游标分页(响应给
next_cursor,请求要带 cursor=)。接口不认识 page,于是每次「加载更多」都原样
返回第一页:一条新评论都加不进去,has_more 又始终是 true,评论页便一遍遍触发
加载更多、反复闪骨架屏——用户看到的就是段评往下滑一闪一闪,评论一条没多。

评论页是上游 HTML 改不了,于是在承载页面的接口代理里做最小适配:
  - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
  - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
    避免页面在「加载更多 → 没有新内容」之间空转。

只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求,其余原样透传。
2026-10-04 16:28:13 +08:00
truewhile d545ff1a7b 修复段评打开后没有评论:承载页面丢失原地址参数
段评气泡上的数字来自正文里的 <comment count>,但点开后的评论页跑在
「/api/reader/browser/page?id=..&s=..」这个承载地址上,页面看到的
location.search 只剩我们自己的 id/s,原地址的 item_id/para/source 全丢了。
评论页正是靠这几个参数去请求 /para_review,于是按缺参数请求,列表永远为空:
外面气泡显示 99,点开一条段评都没有。

承载地址改为把原地址的 query 原样拼在签名参数之后(页面看到的 search 与原站
一致);id/s 放最前,同名的原参数顶不掉我们的签名参数。
2026-10-04 13:53:44 +08:00
truewhile 227e0a9197 优化 2026-10-04 13:29:39 +08:00
truewhile e85df5fb6b 优化 2026-10-04 12:35:33 +08:00
truewhile 6656f9c398 优化 2026-10-04 11:46:17 +08:00
truewhile dacfec7492 优化,bug处理 2026-10-04 01:21:07 +08:00
60 changed files with 4493 additions and 310 deletions
+10 -1
View File
@@ -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))
+4
View File
@@ -95,6 +95,9 @@
- 菜单打开时正文整体下移一个顶栏高度(用 `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+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
@@ -249,4 +252,5 @@
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
@@ -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())
}
}
+13 -2
View File
@@ -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()})
}
+5 -3
View File
@@ -295,12 +295,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
if err != nil {
c.String(http.StatusForbidden, "%s", err.Error())
return
}
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
if err != nil {
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
return
@@ -362,13 +362,15 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
Page int `json:"page"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+10 -1
View File
@@ -104,10 +104,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
View File
@@ -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) {
+15 -2
View File
@@ -56,7 +56,12 @@ func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
@@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
var existing model.ReaderSourceState
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(st).Error
}
@@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
}
st.ID = existing.ID
st.CreatedAt = existing.CreatedAt
if existing.DeletedAt.Valid {
// 历史行被软删过:连 deleted_at 一起写回,把它复活
st.DeletedAt = gorm.DeletedAt{}
return r.db.WithContext(ctx).Unscoped().Save(st).Error
}
return r.db.WithContext(ctx).Save(st).Error
}
+79
View File
@@ -3,6 +3,7 @@ package repository
import (
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
}
}
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
//
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://d.example.com"
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
// 重新导入同 URL 的书源,再存一次状态
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
if err := repo.CreateSource(ctx, again); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
}); err != nil {
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
t.Fatalf("状态未写入: %+v", st)
}
}
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
//
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
const url = "https://e.example.com"
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
}); err != nil {
t.Fatal(err)
}
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
if err := repo.db.Exec(
"update reader_source_states set deleted_at = ? where source_url = ?",
time.Now(), url).Error; err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
}); err != nil {
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
}
st, err := repo.GetSourceState(ctx, url)
if err != nil {
t.Fatal(err)
}
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
t.Fatalf("复活后状态未写入: %+v", st)
}
}
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
func TestImportUpdateKeepsSourceState(t *testing.T) {
repo := newReaderTestRepo(t)
+29 -4
View File
@@ -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
+30 -4
View File
@@ -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)
+94 -5
View File
@@ -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)
}
}
+11
View File
@@ -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 (
+42 -9
View File
@@ -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
+102
View File
@@ -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,239 @@
package reader
import (
"encoding/json"
"net/url"
"sort"
"strconv"
"strings"
"sync"
)
// 本文件:段落评论接口(/para_review)的分页适配。
//
// 上游聚合站自带的评论页按 page=N 翻页(滑到底请求 page=2、page=3…),但它的列表
// 接口早就改成了游标分页:响应里给 next_cursor,请求时要带 cursor=。接口不认识
// page,于是每次「加载更多」都原样返回第一页——一条新评论都加不进去,has_more 又
// 始终是 true,评论页就一遍遍触发加载更多、反复闪骨架屏。用户看到的就是「段评往
// 下滑一闪一闪」,其实评论一条没多。
//
// 评论页是上游 HTML,改不了,于是在代理层做最小适配:
// - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
// - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
// 免得页面在「加载更多 → 没有新内容」之间空转。
//
// 只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求;对不上就原样透传,
// 不影响别的书源。
// commentPageState 一个评论列表(同一段落 + 同一排序)的翻页状态。
type commentPageState struct {
// cursor 上一页响应给的游标:下一页请求用它代替 page。
cursor string
// seen 已经下发给页面的 comment_id:整页重复时据此收尾。
seen map[string]struct{}
}
// commentPager 承载页面里评论接口的翻页适配器(每个承载页面一份)。
type commentPager struct {
mu sync.Mutex
states map[string]*commentPageState
}
func newCommentPager() *commentPager {
return &commentPager{states: map[string]*commentPageState{}}
}
// rewriteRequest 按需把 page=N 改写成 cursor=<上一页游标>。
//
// 返回改写后的地址与状态键(空串表示这个请求不需要适配,调用方也不必观察响应)。
func (p *commentPager) rewriteRequest(target string) (string, string) {
if p == nil {
return target, ""
}
parsed, err := url.Parse(target)
if err != nil || !isCommentListPath(parsed.Path) {
return target, ""
}
query := parsed.Query()
pageRaw := strings.TrimSpace(query.Get("page"))
if pageRaw == "" || strings.TrimSpace(query.Get("cursor")) != "" {
return target, ""
}
page, err := strconv.Atoi(pageRaw)
if err != nil || page < 1 {
return target, ""
}
key := commentPageKey(parsed.Path, query)
if page == 1 {
// 重新加载 / 换了排序:游标与去重表都从头来。
p.mu.Lock()
delete(p.states, key)
p.mu.Unlock()
return target, key
}
p.mu.Lock()
cursor := ""
if st := p.states[key]; st != nil {
cursor = st.cursor
}
p.mu.Unlock()
if cursor == "" {
// 不是从第一页翻过来的(没有游标可用):原样透传,仍然观察响应,
// 靠下面的「整页重复」判断让页面停下来。
return target, key
}
query.Set("cursor", cursor)
query.Del("page")
parsed.RawQuery = query.Encode()
return parsed.String(), key
}
// observeResponse 观察评论列表响应:记下游标,并在整页重复时把 has_more 改成 false。
func (p *commentPager) observeResponse(key, body string) string {
if p == nil || key == "" {
return body
}
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "{") {
return body
}
var env commentListEnvelope
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
return body
}
if env.Data.Comments == nil && env.Data.HasMore == nil {
return body
}
ids := make([]string, 0, len(env.Data.Comments))
for _, c := range env.Data.Comments {
if id := strings.TrimSpace(c.CommentID); id != "" {
ids = append(ids, id)
}
}
p.mu.Lock()
defer p.mu.Unlock()
if p.states == nil {
p.states = map[string]*commentPageState{}
}
st := p.states[key]
if st == nil {
st = &commentPageState{seen: map[string]struct{}{}}
p.states[key] = st
}
repeated := len(ids) > 0
for _, id := range ids {
if _, ok := st.seen[id]; !ok {
repeated = false
break
}
}
if repeated && env.Data.HasMore != nil && *env.Data.HasMore {
// 整页都是老评论:接口不认 page,再翻下去也只会拿到同一页。
// 改掉 has_more,让页面显示「没有更多了」,别再反复拉取闪骨架屏。
if replaced, ok := setFirstJSONBoolFalse(trimmed, "has_more"); ok {
return replaced
}
return body
}
for _, id := range ids {
st.seen[id] = struct{}{}
}
if env.Data.HasMore != nil && !*env.Data.HasMore {
// 已经是最后一页:游标链到头,清掉它,页面万一再要一页也别拿旧游标
// 去请求(那种请求会落在下面的「整页重复」判断上收尾)。
st.cursor = ""
return body
}
// 游标只在拿到新内容时前进:请求失败/重复时页面会重试同一页,
// 游标也跟着重试同一个。
if cursor := rawJSONScalar(env.Data.Cursor); cursor != "" {
st.cursor = cursor
}
return body
}
// commentListEnvelope 评论列表响应里我们关心的字段。
type commentListEnvelope struct {
Data struct {
Comments []struct {
CommentID string `json:"comment_id"`
} `json:"comments"`
HasMore *bool `json:"has_more"`
Cursor json.RawMessage `json:"next_cursor"`
} `json:"data"`
}
// isCommentListPath 判断是否是段落评论列表接口(/para_review)。
func isCommentListPath(path string) bool {
p := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(path)), "/")
return strings.HasSuffix(p, "/para_review")
}
// commentPageKey 生成翻页状态键:接口路径 + 除 page/cursor 之外的查询参数。
// 同一段落的不同排序(sort_by)各自一套状态,互不干扰。
func commentPageKey(path string, query url.Values) string {
rest := make(url.Values, len(query))
for k, v := range query {
if k == "page" || k == "cursor" {
continue
}
rest[k] = v
}
keys := make([]string, 0, len(rest))
for k := range rest {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(path)
for _, k := range keys {
for _, v := range rest[k] {
b.WriteString("|" + k + "=" + v)
}
}
return b.String()
}
// rawJSONScalar 取一个 JSON 标量的字面量(游标是毫秒时间戳,可能被上游写成数字)。
func rawJSONScalar(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
if strings.HasPrefix(s, `"`) {
var out string
if json.Unmarshal(raw, &out) == nil {
return strings.TrimSpace(out)
}
return ""
}
return s
}
// setFirstJSONBoolFalse 把 JSON 里第一个 `"key":true` 改成 `"key":false`。
//
// 只做这一处等长替换,不重新序列化整份响应:评论里的 user_id、时间戳动辄十几位,
// 走一遍 float64 会变成科学计数法,页面再解析就废了。
func setFirstJSONBoolFalse(body, key string) (string, bool) {
token := `"` + key + `"`
idx := strings.Index(body, token)
if idx < 0 {
return body, false
}
i := idx + len(token)
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
i++
}
if i >= len(body) || body[i] != ':' {
return body, false
}
i++
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
i++
}
if !strings.HasPrefix(body[i:], "true") {
return body, false
}
return body[:i] + "false" + body[i+len("true"):], true
}
@@ -0,0 +1,187 @@
package reader
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:段落评论翻页适配(browser_comment_page.go)的测试。
//
// 覆盖两件事:
// 1. page=N(N>1)被换成上一页响应给的 cursor;
// 2. 整页重复(接口不认 page 时的表现)时把 has_more 改成 false,页面才会收尾。
const commentParaBase = "https://cmt.example.com/para_review?item_id=X&para=1&source=QQ"
func TestCommentPagerRewritesPageToCursor(t *testing.T) {
p := newCommentPager()
// 第一页:原样请求,响应里的游标被记下
target, key := p.rewriteRequest(commentParaBase + "&page=1")
if key == "" {
t.Fatal("段落评论接口应被识别")
}
if target != commentParaBase+"&page=1" {
t.Fatalf("第一页不该改写: %q", target)
}
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
// 第二页:改成带 cursor 的请求,page 不再出现
target, key2 := p.rewriteRequest(commentParaBase + "&page=2")
if key2 != key {
t.Fatalf("同一段落的状态键应一致: %q vs %q", key2, key)
}
if !strings.Contains(target, "cursor=1500000000000") || strings.Contains(target, "page=") {
t.Fatalf("第二页应换成 cursor 请求: %q", target)
}
// 拿到新评论:游标前进到下一页
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
target, _ = p.rewriteRequest(commentParaBase + "&page=3")
if !strings.Contains(target, "cursor=1400000000000") {
t.Fatalf("第三页应使用新的游标: %q", target)
}
// 重新加载(page=1)会把整轮状态清掉:没有游标就原样透传
p.rewriteRequest(commentParaBase + "&page=1")
target, _ = p.rewriteRequest(commentParaBase + "&page=2")
if strings.Contains(target, "cursor=") {
t.Fatalf("重置后不该还带着旧游标: %q", target)
}
}
func TestCommentPagerStopsOnRepeatedPage(t *testing.T) {
p := newCommentPager()
_, key := p.rewriteRequest(commentParaBase + "&page=1")
first := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
if got := p.observeResponse(key, first); got != first {
t.Fatalf("第一页不该被改写: %q", got)
}
// 同一页又被返回一次(接口忽略 page):改 has_more,让页面显示「没有更多了」
repeat := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
got := p.observeResponse(key, repeat)
if !strings.Contains(got, `"has_more":false`) {
t.Fatalf("重复页应把 has_more 改成 false: %q", got)
}
// 只动 has_more 一处,其它字段(尤其是大整数)保持原样
if strings.Replace(got, `"has_more":false`, `"has_more":true`, 1) != first {
t.Fatalf("除 has_more 外不应改动响应: %q", got)
}
// 重复页不再推进游标
target, _ := p.rewriteRequest(commentParaBase + "&page=2")
if !strings.Contains(target, "cursor=1500000000000") {
t.Fatalf("游标不该被重复页顶掉: %q", target)
}
}
func TestCommentPagerIgnoresOtherRequests(t *testing.T) {
p := newCommentPager()
cases := []string{
"https://cmt.example.com/book/detail?page=2", // 不是评论接口
"https://cmt.example.com/para_review?item_id=X", // 没有 page
"https://cmt.example.com/para_review?item_id=X&cursor=1", // 已经在用游标
"https://cmt.example.com/para_review?item_id=X&page=0", // page 非法
"https://cmt.example.com/para_review?item_id=X&sort_by=1&", // 没有 page
}
for _, target := range cases {
got, key := p.rewriteRequest(target)
if got != target || key != "" {
t.Fatalf("不该改写的请求被处理了: %q -> %q (key=%q)", target, got, key)
}
}
// 非 JSON 响应原样返回
if got := p.observeResponse("k", "<html></html>"); got != "<html></html>" {
t.Fatalf("非 JSON 响应被改动: %q", got)
}
}
// TestBrowserXHRCommentPagerFollowsCursor 端到端:页面按 page 翻页,
// 代理层换成 cursor,接口给出新评论,最后一页重复时收尾。
func TestBrowserXHRCommentPagerFollowsCursor(t *testing.T) {
svc, _ := newLoginTestService(t)
var mu sync.Mutex
seenCursors := []string{}
seenPages := []string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/page":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, `<html><body>comment page</body></html>`)
case "/para_review":
q := r.URL.Query()
mu.Lock()
seenCursors = append(seenCursors, q.Get("cursor"))
seenPages = append(seenPages, q.Get("page"))
mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// 上游只认 cursor:不认识 page,靠 next_cursor 一页页往前。
switch q.Get("cursor") {
case "":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
case "1500000000000":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
case "1400000000000":
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":false,"next_cursor":""}}`)
default:
// 兜底分支:上游把同一页又给了一遍(页面的 page 循环就会走到这里)
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":true,"next_cursor":"1300000000000"}}`)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
browserCookieTarget{}, rule.BrowserTask{URL: srv.URL + "/page", Title: "段评"}, browserModeOpen)
if err != nil {
t.Fatalf("登记承载页面失败: %v", err)
}
call := func(page string) string {
t.Helper()
res, err := svc.ProxyBrowserXHR(t.Context(), entry.id, http.MethodGet,
srv.URL+"/para_review?item_id=X&para=1&source=QQ&page="+page, nil, "")
if err != nil {
t.Fatalf("代理请求失败: %v", err)
}
return res.Body
}
if body := call("1"); !strings.Contains(body, `"has_more":true`) || !strings.Contains(body, "c1") {
t.Fatalf("第一页响应异常: %q", body)
}
if body := call("2"); !strings.Contains(body, "c2") {
t.Fatalf("第二页应拿到下一页评论: %q", body)
}
if body := call("3"); !strings.Contains(body, "c3") || !strings.Contains(body, `"has_more":false`) {
t.Fatalf("第三页应是真正的最后一页: %q", body)
}
// 页面不知道已经到底,还可能再要一页:上游又把最后一页给了一遍,
// 适配层要把 has_more 改成 false,页面才会显示「没有更多了」并停下。
last := call("4")
if !strings.Contains(last, `"has_more":false`) {
t.Fatalf("整页重复时应收尾(has_more=false): %q", last)
}
mu.Lock()
defer mu.Unlock()
if strings.Join(seenPages, ",") != "1,,,4" {
t.Fatalf("page 参数只应出现在没有游标可用的请求上: %v", seenPages)
}
if strings.Join(seenCursors, ",") != ",1500000000000,1400000000000," {
t.Fatalf("游标传递不符预期: %v", seenCursors)
}
var env map[string]any
if err := json.Unmarshal([]byte(last), &env); err != nil {
t.Fatalf("收尾后的响应不是合法 JSON: %v", err)
}
}
+123 -19
View File
@@ -94,11 +94,18 @@ type pendingBrowser struct {
sourceURL string
userID string
request rule.BrowserTask
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
// 书源会话就能读到登录凭证。
cookies browserCookieTarget
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
html string
finalURL string
mode string
expires time.Time
// comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor,
// 见 browser_comment_page.go)。
comments *commentPager
done chan struct{}
mu sync.Mutex
@@ -113,21 +120,56 @@ type browserHost struct {
sourceURL string
sourceID string
userID string
// state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。
// 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把
// 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。
state *sourceState
capture bool
}
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
}
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
}
// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。
func (h *browserHost) cookieTarget() browserCookieTarget {
if !h.capture {
return browserCookieTarget{}
}
return browserCookieTarget{state: h.state}
}
// browserCookieTarget 面板链路要回写 Cookie 的目标会话。
type browserCookieTarget struct {
state *sourceState
}
// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。
// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。
// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。
func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) {
if t.state == nil || resp == nil {
return
}
sink := cookieSinkFrom(ctx)
if sink == nil {
sink = &cookieSink{}
}
if resp.Request != nil {
sink.add(resp.Request.URL, resp)
}
sink.apply(t.state)
}
// ─── 服务层入口 ────────────────────────────────────────────────────────────
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) {
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait)
if err != nil {
return rule.BrowserResult{}, err
}
@@ -151,16 +193,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u
}
// openBrowser 登记待办但不等待(页面展示给用户即可)。
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error {
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen)
return err
}
// registerBrowser 准备页面内容并登记待办。
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
// 先分配 ID:页面里的资源代理地址需要用它签名。
id := newBrowserID()
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
if err != nil {
return nil, err
}
@@ -173,11 +215,13 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
sourceURL: sourceURL,
userID: userID,
request: req,
cookies: cookies,
html: html,
finalURL: finalURL,
mode: mode,
expires: time.Now().Add(browserPageTTL),
done: make(chan struct{}),
comments: newCommentPager(),
}
s.browserMu.Lock()
@@ -319,7 +363,7 @@ func jsStringEscape(s string) string {
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) {
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
if html := strings.TrimSpace(req.HTML); html != "" {
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
@@ -331,10 +375,16 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
}
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL)
if err != nil {
return "", "", fmt.Errorf("打开页面失败: %w", err)
}
// 空响应没有可展示的内容:以前照样包一层空 <pre> 交给 iframe,面板就是一片白,
// 用户看不出是地址有问题还是自己没登录。这里直接报错(如段评地址参数被改写、
// 上游按缺参数返回空 body 的情形)。
if strings.TrimSpace(body) == "" {
return "", "", fmt.Errorf("目标地址没有返回任何内容: %s", truncateForLog(req.URL, 120))
}
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
if !strings.Contains(strings.ToLower(contentType), "html") {
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
@@ -355,8 +405,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ
}
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) {
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target)
if err != nil {
return "", "", "", err
}
@@ -364,16 +414,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target
}
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
// 返回 (contentType, status, body, error)。
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应
// Set-Cookie 写回(页面可能靠资源响应续期会话)。
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) {
entry := s.lookupBrowser(id)
if entry == nil {
return "", 0, nil, errors.New("页面已过期,请重新打开")
}
contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target)
return contentType, status, data, err
}
// requestBrowserResource 带书源凭据请求一个外部地址。
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) {
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
defer cancel()
// 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。
if cookies.state != nil {
reqCtx = withCookieSink(reqCtx, &cookieSink{})
}
state := s.newSourceState(reqCtx, sourceURL)
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
@@ -419,6 +478,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t
if resp.Request != nil && resp.Request.URL != nil {
finalURL = resp.Request.URL.String()
}
// 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。
cookies.captureFrom(reqCtx, resp)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
@@ -454,8 +515,20 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
if method == "" {
method = http.MethodGet
}
// 段落评论接口:评论页按 page=N 翻页,而接口只认 cursor(见
// browser_comment_page.go)。改写发生在补书源凭据之前,Cookie/Referer
// 仍然按目标站点选取。
pagerKey := ""
if entry.comments != nil {
target, pagerKey = entry.comments.rewriteRequest(target)
}
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
defer cancel()
// 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的
// Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。
if entry.cookies.state != nil {
reqCtx = withCookieSink(reqCtx, &cookieSink{})
}
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
if err != nil {
@@ -506,6 +579,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
return nil, err
}
data = helper.DecompressBody(resp, data)
// 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。
entry.cookies.captureFrom(reqCtx, resp)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
@@ -513,6 +588,9 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
if isTextualContent(contentType) {
out.Body = string(data)
if entry.comments != nil && pagerKey != "" {
out.Body = entry.comments.observeResponse(pagerKey, out.Body)
}
} else {
out.Body = base64.StdEncoding.EncodeToString(data)
out.Base64 = true
@@ -665,8 +743,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string {
return s.browserSign("asset|" + id + "|" + target)
}
func (s *ReaderService) browserPageURL(id string) string {
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
// browserPageURL 生成 iframe 承载地址。
//
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id&para&source
// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的
// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去
// 请求评论接口,气泡数照样显示,点开却一条段评都没有。
//
// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。
// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。
func (s *ReaderService) browserPageURL(id, target string) string {
u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
if q := browserTargetQuery(target); q != "" {
u += "&" + q
}
return u
}
// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。
func browserTargetQuery(target string) string {
target = strings.TrimSpace(target)
if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") {
return ""
}
parsed, err := url.Parse(target)
if err != nil {
return ""
}
return parsed.RawQuery
}
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
@@ -824,7 +928,7 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
Title: e.request.Title,
Mode: e.mode,
Seq: e.seq,
PageURL: s.browserPageURL(e.id),
PageURL: s.browserPageURL(e.id, e.request.URL),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
TargetURL: target,
+50 -5
View File
@@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
}
}
// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。
//
// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。
// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有,
// 点开却一条评论都没有。
func TestBrowserPageURLKeepsTargetQuery(t *testing.T) {
svc, _ := newLoginTestService(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body>comment page</body></html>`))
}))
defer srv.Close()
// 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。
target := srv.URL + "/get_para_review?item_id=abc&para=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy"
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen)
if err != nil {
t.Fatalf("登记承载页面失败: %v", err)
}
page := svc.browserPageOf(entry)
u, err := url.Parse(page.PageURL)
if err != nil {
t.Fatalf("承载地址不合法: %q", page.PageURL)
}
q := u.Query()
if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" {
t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL)
}
// 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办
if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) {
t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL)
}
if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil {
t.Fatalf("带业务参数后签名校验失败: %v", err)
}
// 没有 query 的地址(含 data: 页面)不该多出参数
for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} {
if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 {
t.Fatalf("无 query 的地址被拼进了多余参数: %q", got)
}
}
}
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
@@ -201,7 +246,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
st.flush()
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
URL: srv.URL + "/user", Title: "用户后台",
}); err != nil {
t.Fatalf("打开用户后台失败: %v", err)
@@ -266,7 +311,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
if gotSourceURL != srv.URL {
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
}
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
if err != nil {
t.Fatalf("拉取资源失败: %v", err)
}
@@ -313,7 +358,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) {
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
st.flush()
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
URL: srv.URL + "/user", Title: "用户后台",
}); err != nil {
t.Fatalf("打开用户后台失败: %v", err)
@@ -403,7 +448,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
t.Fatalf("登记 open 页面失败: %v", err)
}
@@ -421,7 +466,7 @@ func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
}
// 再点别的按钮:待办表里只应剩新页面,且序号更大
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID,
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
t.Fatalf("登记新页面失败: %v", err)
}
+2
View File
@@ -451,6 +451,8 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
title = "段评"
}
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
// 段评是「只展示」的页面,没有登录会话可回写 Cookie。
browserCookieTarget{},
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
if err != nil {
return nil, err
@@ -0,0 +1,181 @@
package reader
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。
//
// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里
// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的
// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档,
// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。
// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302,
// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」
// 能被分开断言。
func cookieCaptureServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/",
})
http.Redirect(w, r, "/done", http.StatusFound)
})
mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"})
_, _ = w.Write([]byte(`{"ok":true}`))
})
mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`<html><body>面板</body></html>`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie
// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。
func TestExecuteCapturesRedirectHopCookies(t *testing.T) {
srv := cookieCaptureServer(t)
svc, _ := newLoginTestService(t)
ctx := t.Context()
state := svc.newSourceState(ctx, srv.URL)
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil {
t.Fatalf("请求失败: %v", err)
}
// 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got)
}
// 最终响应的 Cookie 按该跳地址归档。
if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") {
t.Fatalf("最终响应的 Cookie 没有被收下: %q", got)
}
}
// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时
// 主链路不应自动累积 Cookie。
func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) {
srv := cookieCaptureServer(t)
svc, _ := newLoginTestService(t)
ctx := t.Context()
state := svc.newSourceState(ctx, srv.URL)
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil {
t.Fatalf("请求失败: %v", err)
}
if got := state.GetCookie("https://example.com/"); got != "" {
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
}
if got := state.GetCookie(srv.URL); got != "" {
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
}
}
// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经
// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。
// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。
func TestProxyBrowserXHRCapturesCookies(t *testing.T) {
srv := cookieCaptureServer(t)
svc, _ := newLoginTestService(t)
ctx := t.Context()
const sourceURL = "https://api.example.com"
const sourceID = "panel-source-1"
state := svc.newSourceState(ctx, sourceURL)
// 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。
if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID,
browserCookieTarget{state: state},
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
t.Fatalf("登记页面失败: %v", err)
}
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
if len(pages) != 1 {
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
}
// 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。
if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil {
t.Fatalf("代理请求失败: %v", err)
}
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
t.Fatalf("面板请求的凭证没有写回会话: %q", got)
}
// 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。
state.flush()
reloaded := svc.newSourceState(ctx, sourceURL)
if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
t.Fatalf("面板拿到的凭证没有落库: %q", got)
}
}
// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板)
// 代理请求照常工作,只是不做 Cookie 累积。
func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) {
srv := cookieCaptureServer(t)
svc, _ := newLoginTestService(t)
ctx := t.Context()
const sourceID = "panel-source-2"
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID,
browserCookieTarget{},
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
t.Fatalf("登记页面失败: %v", err)
}
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
if len(pages) != 1 {
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
}
res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "")
if err != nil {
t.Fatalf("代理请求应照常成功: %v", err)
}
if res.Status != http.StatusOK {
t.Fatalf("代理状态 = %d", res.Status)
}
}
// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。
func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) {
st := &sourceState{cookies: map[string]string{}}
if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil {
t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie")
}
if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st {
t.Fatal("开启时应把会话交给面板回写 Cookie")
}
}
// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的,
// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。
func TestSourceStateCookieConcurrency(t *testing.T) {
st := &sourceState{cookies: map[string]string{}}
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
url := "https://example.com/"
st.SetCookie(url, "k=1")
_ = st.GetCookie(url)
_ = st.snapshotCookies()
}(i)
}
wg.Wait()
if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") {
t.Fatalf("并发写入后应能读到 Cookie: %q", got)
}
}
+148
View File
@@ -0,0 +1,148 @@
package reader
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"sync"
)
// 本文件:把响应下发的 Set-Cookie 收进书源会话。
//
// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。
// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带
// Set-Cookie,再跳到业务域名)这三个做法都会漏:
//
// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到;
// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域
// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶,
// 之后 api.bilibili.com 的请求取不到这份 Cookie;
// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库,
// 页面在面板里「登录成功」但书源会话始终是空的。
//
// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie,
// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。
// capturedCookie 一条待写入会话的 Cookie 及其归属地址。
type capturedCookie struct {
// url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点),
// 否则用下发它的那一跳的请求地址。
url string
cookie string
}
// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。
// 并发安全:面板里的多个资源/接口请求会同时走这里。
type cookieSink struct {
mu sync.Mutex
items []capturedCookie
}
// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。
func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) {
if s == nil || resp == nil {
return
}
items := make([]capturedCookie, 0, len(resp.Cookies()))
for _, ck := range resp.Cookies() {
if ck == nil || ck.Name == "" {
continue
}
target := ""
if d := strings.TrimSpace(ck.Domain); d != "" {
target = strings.TrimPrefix(d, ".")
} else if reqURL != nil {
target = reqURL.String()
}
if target == "" {
continue
}
items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value})
}
if len(items) == 0 {
return
}
s.mu.Lock()
s.items = append(s.items, items...)
s.mu.Unlock()
}
// drain 取出并清空已收集的 Cookie。
func (s *cookieSink) drain() []capturedCookie {
if s == nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
out := s.items
s.items = nil
return out
}
// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。
func (s *cookieSink) apply(state *sourceState) {
if s == nil {
return
}
items := s.drain()
if state == nil || len(items) == 0 {
return
}
for _, it := range items {
state.SetCookie(it.url, it.cookie)
}
}
// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ──────────────
type cookieSinkKey struct{}
// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。
func withCookieSink(ctx context.Context, sink *cookieSink) context.Context {
if sink == nil {
return ctx
}
return context.WithValue(ctx, cookieSinkKey{}, sink)
}
// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。
func cookieSinkFrom(ctx context.Context) *cookieSink {
if ctx == nil {
return nil
}
sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink)
return sink
}
// captureRedirectCookies 作为 http.Client.CheckRedirect:
// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。
//
// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致
// (10 次),所以装上它不改变既有链路的行为。
func captureRedirectCookies(req *http.Request, via []*http.Request) error {
if req != nil {
if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil {
// via 的最后一跳才是产生 req.Response 的那个请求。
prev := req
if n := len(via); n > 0 {
prev = via[n-1]
}
sink.add(prev.URL, req.Response)
}
}
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。
// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。
func enableCookieCapture(c *http.Client) {
if c == nil {
return
}
c.CheckRedirect = captureRedirectCookies
}
+5 -5
View File
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员", nil)
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
+63 -15
View File
@@ -13,6 +13,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -63,11 +64,14 @@ type ReaderService struct {
// NewReaderService 创建服务。
func NewReaderService(cfg *config.Config, log *zap.Logger, repos *repository.Container) *ReaderService {
client := helper.NewSiteHTTPClient(30, true)
// 逐跳收集 Set-Cookie:登录源常把凭证放在跳转链中间那一跳(见 cookies.go)。
enableCookieCapture(client)
return &ReaderService{
cfg: cfg,
log: log,
repo: repos.Reader,
http: helper.NewSiteHTTPClient(30, true),
http: client,
crypto: helper.NewSecretCipher(firstNonEmpty(cfg.Secrets.EncryptionKey, cfg.Secrets.JWTSecret)),
limiter: newSourceRateLimiter(),
}
@@ -319,12 +323,13 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
}
}
// 请求构造抽成闭包:重试时必须重建请求(body 读取器只能消费一次)。
buildRequest := func() (*http.Request, error) {
// reqCtx 每次尝试单独传入,好让 Set-Cookie 只从「最终那次尝试」收集。
buildRequest := func(reqCtx context.Context) (*http.Request, error) {
var bodyReader io.Reader
if req.Body != "" {
bodyReader = strings.NewReader(req.Body)
}
httpReq, err := http.NewRequestWithContext(ctx, req.Method, target, bodyReader)
httpReq, err := http.NewRequestWithContext(reqCtx, req.Method, target, bodyReader)
if err != nil {
return nil, err
}
@@ -361,9 +366,18 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
var (
resp *http.Response
data []byte
// sink 收集这次请求(含重定向各跳)下发的 Set-Cookie,
// 只在需要回写会话且书源允许自动累积时才挂。
sink *cookieSink
)
for attempt := 1; ; attempt++ {
httpReq, err := buildRequest()
sink = nil
reqCtx := ctx
if state != nil && captureCookies {
sink = &cookieSink{}
reqCtx = withCookieSink(ctx, sink)
}
httpReq, err := buildRequest(reqCtx)
if err != nil {
return "", "", 0, err
}
@@ -391,6 +405,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
break
}
data = helper.DecompressBody(resp, data)
// Raw:按原始字节返回(书源文件落盘用),不做 charset 解码。
// 必须先于 HexBody 判断之外处理:GBK 文本一旦按 UTF-8 解码就变成 U+FFFD,
// 落盘的文件会坏掉。
if req.Raw {
return string(data), resp.Request.URL.String(), resp.StatusCode, nil
}
// 声明了 type 的请求按「原始字节的 hex」返回(对应 legado AnalyzeUrl.type),
// 不做 charset 解码——书源会自己 hexDecodeToString 取回内容。
if req.HexBody {
@@ -405,15 +425,12 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
body = string(data)
}
finalURL := resp.Request.URL.String()
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)
var cookieStrs []string
for _, ck := range resp.Cookies() {
cookieStrs = append(cookieStrs, ck.Name+"="+ck.Value)
}
if len(cookieStrs) > 0 && state != nil && captureCookies {
for _, ck := range cookieStrs {
state.SetCookie(finalURL, ck)
}
// 记录 Set-Cookie(书源 JS 的 cookie.getCookie 可读取)。
// sink 里已含重定向各跳的 Cookie,这里再把最终响应补进去,
// 归属域按 Cookie 自己的 Domain 优先、否则按该跳地址(见 cookies.go)。
if sink != nil {
sink.add(resp.Request.URL, resp)
sink.apply(state)
}
// bodyJs 二次处理
if req.BodyJsFn != nil {
@@ -606,6 +623,11 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
sourceURL: sess.srcURL(),
sourceID: sess.srcID(),
userID: sess.userID,
// state 交出去,让面板里页面自己发起的请求也能把 Set-Cookie
// 写回这次会话(见 browser_panel.go / cookies.go)。
state: sess.state,
// capture 对应 enabledCookieJar:关掉时面板同样不自动累积 Cookie。
capture: sess.captureCookies(),
}
}
return rule.NewJSRunner(rule.JSConfig{
@@ -626,9 +648,28 @@ func (sess *sourceSession) runner(key string, page int) *rule.JSRunner {
JSLib: SPtr(sess.bs.JSLib),
Ctx: sess.ctx,
Browser: host,
// 书源文件缓存根目录:java.downloadFile / cacheFile 落盘用。
CacheDir: sess.svc.readerFileCacheDir(),
})
}
// readerFileCacheDir 书源文件缓存根目录(java.downloadFile / cacheFile)。
// 优先用配置的 cache_dir,退回 data_dir/cache;都不可用时返回空串,
// 此时文件类 JS 接口会抛出「未配置缓存目录」而不是写到进程当前目录。
func (s *ReaderService) readerFileCacheDir() string {
if s == nil || s.cfg == nil {
return ""
}
if base := strings.TrimSpace(s.cfg.Cache.CacheDir); base != "" {
return base
}
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
return filepath.Join(dataDir, "cache")
}
return ""
}
// srcID 书源记录 ID(登录界面待办按书源隔离)。
func (sess *sourceSession) srcID() string {
if sess.src != nil {
@@ -751,7 +792,14 @@ type searchHit struct {
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
//
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
if page < 1 {
page = 1
}
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, nil, err
@@ -775,7 +823,7 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri
g.Go(func() error {
gctxSrc, cancel := context.WithTimeout(gctx, perSourceTimeout)
defer cancel()
books, err := s.searchInSource(gctxSrc, &src, nil, key, 1)
books, err := s.searchInSource(gctxSrc, &src, nil, key, page)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -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()
}
+45 -4
View File
@@ -2,6 +2,7 @@ package rule
import (
"net/url"
"regexp"
"strings"
"github.com/PaesslerAG/jsonpath"
@@ -450,10 +451,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
if result == nil {
result = ""
}
str := resultString(result)
if strings.Contains(str, "&") {
str = html.UnescapeString(str)
}
str := unescapeRuleEntities(resultString(result))
if isUrl {
if strings.TrimSpace(str) == "" {
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
@@ -597,6 +595,49 @@ func applyReplaceRegex(result string, r ResolvedSourceRule) string {
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
}
// entityRefRe 匹配一个 HTML 字符引用:命名实体(&amp;、&nbsp;)、十进制(&#39;)、
// 十六进制(&#x27;)。分号可选——旧式简写(&nbsp、&amp)在 HTML 文本里同样成立。
var entityRefRe = regexp.MustCompile(`&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?`)
// unescapeRuleEntities 还原规则结果里的 HTML 实体(`&amp;` → `&`、`&#39;` → `'`)。
//
// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
// `…?item_id=X&para=1&source=QQ阅读` 会变成 `…?item_id=X¶=1&source=QQ阅读`,
// 上游按缺参数处理返回空页面,段评面板就只剩一片空白(见 reader/comment.go)。
func unescapeRuleEntities(s string) string {
if !strings.Contains(s, "&") {
return s
}
var b strings.Builder
last := 0
for _, loc := range entityRefRe.FindAllStringIndex(s, -1) {
match := s[loc[0]:loc[1]]
if !strings.HasSuffix(match, ";") && loc[1] < len(s) && isEntityStopByte(s[loc[1]]) {
continue
}
decoded := html.UnescapeString(match)
if decoded == match {
continue // 不是已知实体,原样保留
}
b.WriteString(s[last:loc[0]])
b.WriteString(decoded)
last = loc[1]
}
if last == 0 {
return s
}
b.WriteString(s[last:])
return b.String()
}
// isEntityStopByte 判断旧式简写实体后面这个字符是否让它失去实体语义
// (HTML5 规定不带分号的引用后跟 = 或字母数字时按普通文本处理)。
func isEntityStopByte(c byte) bool {
return c == '=' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
}
// resultString 对应 Kotlin 的 result.toString()。
func resultString(result any) string {
switch t := result.(type) {
@@ -0,0 +1,80 @@
package rule
import (
"strings"
"testing"
)
// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 &para 吃成
// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{
name: "段评地址里的 &para 不能被当成实体",
in: "https://v1.qingtian618.com/get_para_review?item_id=X&para=1&source=QQ%E9%98%85%E8%AF%BB",
want: "https://v1.qingtian618.com/get_para_review?item_id=X&para=1&source=QQ%E9%98%85%E8%AF%BB",
},
{
name: "&not 开头的参数名同样不能被吃掉",
in: "https://example.com/api?book=1&notify=1",
want: "https://example.com/api?book=1&notify=1",
},
{
name: "带分号的实体照旧还原",
in: "url?a=1&amp;b=2&amp;c=3",
want: "url?a=1&b=2&c=3",
},
{
name: "数字实体照旧还原",
in: "&#39;引号&#39; &#x27;x&#x27; &#169;",
want: "'引号' 'x' ©",
},
{
name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
in: "第一段&nbsp第二段&copy 2026&times",
want: "第一段\u00a0第二段© 2026×",
},
{
name: "不是实体的 & 原样保留",
in: "a & b &foo; c",
want: "a & b &foo; c",
},
{
name: "不含 & 的字符串直接放行",
in: "魔法大陆。",
want: "魔法大陆。",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := unescapeRuleEntities(c.in); got != c.want {
t.Fatalf("unescapeRuleEntities(%q) = %q,期望 %q", c.in, got, c.want)
}
})
}
}
// 端到端:走 GetString 的规则结果同样不能把段评地址里的 &para 吃掉
// (光遇聚合的正文规则是 <js>…</js>$.content 形态,最终取 $.content)。
func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ&para=11&source=QQ%E9%98%85%E8%AF%BB"
payload := `{"content":"<p>正文<comment ident=\"` + url + `\" count=\"39\" /></p>"}`
a := NewAnalyzeRule()
a.SetContent(payload, "https://v1.qingtian618.com")
got, err := a.GetString("$.content", nil, false)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if !strings.Contains(got, "&para=11") {
t.Fatalf("段评地址里的 &para=11 被改写了: %q", got)
}
if strings.Contains(got, "\u00b6") {
t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
}
}
+95 -2
View File
@@ -10,6 +10,8 @@ import (
"encoding/hex"
"fmt"
"hash"
"os"
"path/filepath"
"strings"
"github.com/dop251/goja"
@@ -435,6 +437,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 +631,15 @@ 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", "服务端不支持动态加载外部脚本"))
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
}
+152
View File
@@ -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)
}
}
+121 -1
View File
@@ -72,6 +72,9 @@ type JSConfig struct {
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
// nil 时这两个函数抛出不支持错误。
Browser BrowserHost
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
// 为空时这些函数抛出明确错误。
CacheDir string
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
Ctx context.Context
}
@@ -211,11 +214,100 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
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 +491,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 +533,31 @@ 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 // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
}
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
func exportValue(v goja.Value) any {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
@@ -495,6 +612,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 {
+40 -3
View File
@@ -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)
}
}
+41 -1
View File
@@ -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)
}
}
+4
View File
@@ -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 表示该请求依赖当前阶段不支持的能力,
@@ -0,0 +1,98 @@
package reader
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// 本文件:搜索分页。
//
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
func pagedSearchSourceJSON(serverURL string) string {
src := map[string]any{
"bookSourceUrl": serverURL,
"bookSourceName": "分页源",
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
"ruleSearch": map[string]any{
"bookList": "$.data",
"name": "$.book_name",
"author": "$.author",
"bookUrl": "$.book_url",
},
}
out, _ := json.Marshal(src)
return string(out)
}
func TestSearchUsesRequestedPage(t *testing.T) {
var gotPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPage = r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
payload := map[string]any{"data": []map[string]string{{
"book_name": fmt.Sprintf("第%s页的书", gotPage),
"author": "作者",
"book_url": "/book/" + gotPage,
}}}
_ = json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
for _, tc := range []struct {
in int
want string
}{
{1, "1"},
{2, "2"},
{5, "5"},
{0, "1"}, // 非法页码退回第 1 页
{-3, "1"}, // 负数同理
} {
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
if err != nil {
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
}
if len(skipped) > 0 {
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
}
if len(books) != 1 {
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
}
if gotPage != tc.want {
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
}
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
}
}
}
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
books, _, err := svc.Search(t.Context(), "书", nil, 3)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
if len(books) != 1 || books[0].Name != "单页书" {
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
}
}
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out))
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
t.Fatalf("搜索失败: %v", err)
}
if gotQuery != "宠魅" {
+3 -3
View File
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 指定单个源:只应搜到该源的结果
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
if err != nil {
t.Fatal(err)
}
@@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 空范围:默认全部启用书源,两个源都应命中
books, _, err = svc.Search(t.Context(), "书", nil)
books, _, err = svc.Search(t.Context(), "书", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
if err != nil {
t.Fatal(err)
}
+24 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"strings"
"sync"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/reader/rule"
@@ -23,8 +24,12 @@ type sourceState struct {
variable string
loginInfo string
loginHeader string
cookies map[string]string // domain → "k=v; k=v"
loaded bool
// cookies / cookieMu:domain → "k=v; k=v"。
// cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写
// Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。
cookies map[string]string
cookieMu sync.Mutex
loaded bool
toasts []string
browsers []rule.BrowserRequest
@@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) {
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
func (st *sourceState) GetCookieKey(rawURL, key string) string {
st.cookieMu.Lock()
defer st.cookieMu.Unlock()
return st.getCookieKeyLocked(rawURL, key)
}
// getCookieKeyLocked 的调用方需持有 cookieMu。
func (st *sourceState) getCookieKeyLocked(rawURL, key string) string {
domain := rule.CookieDomain(rawURL)
if domain == "" {
return ""
@@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) {
if domain == "" || strings.TrimSpace(cookie) == "" {
return
}
st.cookieMu.Lock()
defer st.cookieMu.Unlock()
merged := rule.MergeCookie(st.cookies[domain], cookie)
if merged == st.cookies[domain] {
return
@@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) {
if domain == "" {
return
}
st.cookieMu.Lock()
defer st.cookieMu.Unlock()
if _, ok := st.cookies[domain]; !ok {
return
}
@@ -197,12 +213,14 @@ func (st *sourceState) flush() {
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
return
}
st.cookieMu.Lock()
cookiesJSON := ""
if len(st.cookies) > 0 {
if b, err := json.Marshal(st.cookies); err == nil {
cookiesJSON = string(b)
}
}
st.cookieMu.Unlock()
rec := &model.ReaderSourceState{
SourceURL: st.sourceURL,
Variable: st.variable,
@@ -221,6 +239,8 @@ func (st *sourceState) flush() {
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
func (st *sourceState) snapshotCookies() map[string]string {
st.cookieMu.Lock()
defer st.cookieMu.Unlock()
out := make(map[string]string, len(st.cookies))
for d, c := range st.cookies {
out[d] = c
@@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string {
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
func (st *sourceState) clearCookies() {
st.cookieMu.Lock()
defer st.cookieMu.Unlock()
if len(st.cookies) == 0 {
return
}
+9
View File
@@ -237,6 +237,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)
+3 -2
View File
@@ -268,11 +268,12 @@ export const readerAPI = {
// ── 搜索 / 详情 / 目录 / 正文 ──
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
search: (key: string, sourceIds: string[] = []) =>
// page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。
search: (key: string, sourceIds: string[] = [], page = 1) =>
api
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
'/reader/search',
{ key, source_ids: sourceIds },
{ key, source_ids: sourceIds, page },
longOpts,
)
.then((r) => r.data),
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
type LoadMoreSentinelProps = {
/** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
/** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */
error?: string
onRetry?: () => void
label?: string
loadingLabel?: string
}
/**
* 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调
* onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。
*
* 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会
* 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空
* 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。
*/
export function LoadMoreSentinel({
hasMore,
loadingMore,
onLoadMore,
error = '',
onRetry,
label = '加载更多',
loadingLabel = '加载中…',
}: LoadMoreSentinelProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = ref.current
if (!element || !hasMore || error) return
// 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。
const root = document.getElementById('app-main-scroll')
const observer = new IntersectionObserver(
(entries) => {
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
onLoadMore()
}
},
{ root, rootMargin: '600px 0px', threshold: 0 },
)
observer.observe(element)
return () => observer.disconnect()
}, [hasMore, loadingMore, onLoadMore, error])
if (!hasMore) return null
const showRetry = !!error && !!onRetry
return (
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
<button
type="button"
disabled={loadingMore}
onClick={showRetry ? onRetry : onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? loadingLabel : label}
</button>
</div>
)
}
+23 -15
View File
@@ -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 -2
View File
@@ -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) {
+153
View File
@@ -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 }
}
+3 -48
View File
@@ -1,6 +1,7 @@
import { memo, useEffect, useRef, type ReactNode } from 'react'
import { memo, type ReactNode } from 'react'
import { Film } from 'lucide-react'
import { LoadMoreSentinel } from '../components/LoadMoreSentinel'
import { MediaCard } from '../components/MediaCard'
import { MEDIA_GRID_CLASS, VirtualMediaGrid } from '../components/VirtualMediaGrid'
import type { Media } from '../types'
@@ -67,57 +68,11 @@ export function LibraryMediaSections({
<LibraryEmptyState message="该库尚未发现任何剧集,触发一次扫描后再来看看" />
)}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={onLoadMore}
/>
<LoadMoreSentinel hasMore={hasMore} loadingMore={loadingMore} onLoadMore={onLoadMore} />
</>
)
}
function LoadMoreSentinel({
hasMore,
loadingMore,
onLoadMore,
}: {
hasMore: boolean
loadingMore: boolean
onLoadMore: () => void
}) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = ref.current
if (!element || !hasMore) return
const root = document.getElementById('app-main-scroll')
const observer = new IntersectionObserver(
(entries) => {
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
onLoadMore()
}
},
{ root, rootMargin: '600px 0px', threshold: 0 },
)
observer.observe(element)
return () => observer.disconnect()
}, [hasMore, loadingMore, onLoadMore])
if (!hasMore) return null
return (
<div ref={ref} className="flex justify-center py-8">
<button
type="button"
disabled={loadingMore}
onClick={onLoadMore}
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
>
{loadingMore ? '加载中…' : '加载更多'}
</button>
</div>
)
}
// 独立 memo 组件:onClick 闭包在其内部创建,props 均为稳定引用,
// 父级重渲染不会穿透到每张剧集卡片。
const SeriesCardItem = memo(function SeriesCardItem({
+106 -11
View File
@@ -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(
+26 -4
View File
@@ -5,6 +5,7 @@ import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, R
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { pickChapterIndex } from './bookDetailModel'
import { SourcePickerDialog } from './SourcePickerDialog'
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
@@ -86,7 +87,16 @@ export default function ReaderBookPage() {
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
const startReading = async () => {
/**
* 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。
*/
const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise<number> => {
const cached = await readerAPI.listChapters(bookID).catch(() => [])
return pickChapterIndex(cached, chapter)
}
// chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。
const startReading = async (chapter?: ReaderTocChapter) => {
setBusy(true)
try {
let book = shelfBook
@@ -105,7 +115,9 @@ export default function ReaderBookPage() {
cover_url: cover,
})
}
navigate(`/reader/view/${book.id}`)
// 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。
const index = chapter ? await resolveChapterIndex(book.id, chapter) : null
navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
} finally {
@@ -273,11 +285,11 @@ export default function ReaderBookPage() {
<Trash2 size={13} className="mr-1 inline" /> 移出书架
</button>
) : (
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<BookOpen size={13} className="mr-1 inline" /> 加入书架
</button>
)}
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
<button type="button" onClick={() => startReading()} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
</button>
@@ -318,6 +330,16 @@ export default function ReaderBookPage() {
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
{c.is_volume ? (
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
) : c.url ? (
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
<button
type="button"
disabled={busy}
onClick={() => startReading(c)}
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
>
{c.title}
</button>
) : (
c.title
)}
+47 -7
View File
@@ -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>
)
}
+86 -13
View File
@@ -4,26 +4,26 @@ import toast from 'react-hot-toast'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
import { LoadMoreSentinel } from '../../components/LoadMoreSentinel'
import ReaderBookCover from '../../components/ReaderBookCover'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { splitKindTags } from '../../utils/kindTags'
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from '../../utils/searchBooks'
import { SearchScopeDialog } from './SearchScopeDialog'
import { SourcePickerDialog } from './SourcePickerDialog'
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
// 结果按页取:滚到底自动请求下一页并增量合并,直到某一页不再带来新书。
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
function shelfBookKey(name: string, author: string): string {
const n = name.trim()
const a = author.trim()
return a ? `${n}|${a}` : n
}
/** 单次搜索最多翻到第几页。书源分页规则千奇百怪,个别源会一直返回新条目,
* 不设上限时用户一直往下滚就会一直发请求(每次都要把所有源再搜一遍)。 */
const MAX_SEARCH_PAGES = 20
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
/** 这本搜索结果是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
if (shelf.has(shelfBookKey(book.name, book.author))) return true
if (shelf.has(searchBookKey(book.name, book.author))) return true
if (book.book_url && shelf.has(book.book_url)) return true
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
}
@@ -39,6 +39,16 @@ export default function ReaderSearchPage() {
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
const [showSkipped, setShowSkipped] = useState(false)
const [adding, setAdding] = useState('')
// 滚动加载:page 是已加载到的页码;hasMore 为 false 表示到底了(或还没搜过)。
const [page, setPage] = useState(0)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [moreError, setMoreError] = useState('')
// loadMore 的在途标记(见 loadMore 里的说明),比 loadingMore 状态更早生效。
const loadingMoreRef = useRef(false)
// 每次新搜索 +1:在飞的 loadMore 回来时若已经换了关键词(searchSeq 已变),
// 就把那一页结果丢掉,避免旧关键词的第 N 页被并进新列表。
const searchSeq = useRef(0)
const inputRef = useRef<HTMLInputElement>(null)
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
@@ -69,7 +79,7 @@ export default function ReaderSearchPage() {
.then((books) => {
const keys = new Set<string>()
for (const b of books) {
keys.add(shelfBookKey(b.name, b.author))
keys.add(searchBookKey(b.name, b.author))
if (b.book_url) keys.add(b.book_url)
}
setShelfKeys(keys)
@@ -89,17 +99,26 @@ export default function ReaderSearchPage() {
const doSearch = async () => {
const kw = key.trim()
if (!kw || searching) return
searchSeq.current++
setSearching(true)
setShowSkipped(false)
setLoadingMore(false)
setMoreError('')
try {
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
const res = await readerAPI.search(kw, scopeIds)
setBooks(res.books ?? [])
const res = await readerAPI.search(kw, scopeIds, 1)
const list = res.books ?? []
setBooks(list)
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
setSkipped(res.skipped ?? [])
if ((res.books ?? []).length === 0) toast.error('所有书源都没有找到结果')
setPage(1)
// 首页一条都没有 → 后面也不会有;有结果就先假定还有下一页。
// 书源是否支持分页只有它自己知道({{page}} 也可能藏在 URL 的 <js> 里),
// 所以这里不预判:多花一次「第 2 页」请求,由 loadMore 发现这一页没带来新书时收口。
setHasMore(list.length > 0)
if (list.length === 0) toast.error('所有书源都没有找到结果')
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
toast.error(msg)
@@ -108,6 +127,47 @@ export default function ReaderSearchPage() {
}
}
// 滚动到底加载下一页。每页都会把范围内的书源重新搜一遍(后端按页下发),
// 因此这里要注意两件事:
// 1. 结果必须增量合并而不是替换——不支持 {{page}} 的源会在每一页重复返回首页结果;
// 2. 合并后没有新书(重复内容或该页为空)就说明到底了,否则用户会一直滚一直转圈。
const loadMore = async () => {
const kw = key.trim()
if (!kw || !hasMore || loadingMore || searching || books === null) return
// loadingMore 是渲染后才生效的状态:同一帧里观察器可能回调两次,用 ref 兜住,
// 避免把同一页请求两次(每次翻页都要把所有源再搜一遍,重复请求不便宜)。
if (loadingMoreRef.current) return
loadingMoreRef.current = true
const next = page + 1
if (next > MAX_SEARCH_PAGES) {
setHasMore(false)
loadingMoreRef.current = false
return
}
setLoadingMore(true)
setMoreError('')
const seq = searchSeq.current
try {
const res = await readerAPI.search(kw, scopeIds, next)
// 期间用户又发起了一次搜索:这一页属于旧关键词,直接丢弃。
if (searchSeq.current !== seq) return
const incoming = res.books ?? []
const merged = mergeSearchBooks(books, incoming)
setBooks(merged)
setSkipped((prev) => mergeSearchSkipped(prev, res.skipped ?? []))
setPage(next)
if (merged.length === books.length) setHasMore(false)
} catch (e) {
if (searchSeq.current !== seq) return
// 不弹 toast 打断浏览:把失败原因挂在哨兵上,改由用户点重试。
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载下一页失败'
setMoreError(msg)
} finally {
loadingMoreRef.current = false
setLoadingMore(false)
}
}
// 从首页顶部书搜索带 ?key= 进来时自动搜一次(只做一次,之后由用户手动搜)
const autoSearchedRef = useRef(false)
useEffect(() => {
@@ -127,7 +187,7 @@ export default function ReaderSearchPage() {
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
setShelfKeys((prev) => {
const next = new Set(prev)
next.add(shelfBookKey(book.name, book.author))
next.add(searchBookKey(book.name, book.author))
if (origin.book_url) next.add(origin.book_url)
return next
})
@@ -277,6 +337,19 @@ export default function ReaderSearchPage() {
</div>
))}
{/* 滚动到底自动加载下一页;失败时改成手动重试(见 LoadMoreSentinel)。 */}
<LoadMoreSentinel
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
error={moreError}
onRetry={loadMore}
loadingLabel={`正在搜索第 ${page + 1} 页…`}
/>
{!hasMore && !moreError && visibleBooks.length > 0 && (
<p className="py-2 text-center text-2xs text-[var(--app-subtle)]">已经到底了</p>
)}
{skipped.length > 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
<button
+669 -112
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
@@ -28,6 +28,7 @@ import {
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
@@ -43,6 +44,19 @@ import { getReaderAudioEngine } from './readerAudioEngine'
const COLUMN_GAP = 48
/**
* 正文「保持真实渲染」的页窗口:当前页 ± 这么多页的段落不做 content-visibility 跳过。
*
* 为什么要有这个窗口:被跳过的段落不能每帧重算可见性。翻页/拖动时内容每移动一点,
* 进入或离开视口的段落都要重新判定,而每次判定变化都会重排整条多栏流——实测一次
* 滑动触发 32 次 layout(20 次滑动主线程 10.6s,JS 只占 0.014s),帧根本画不完,
* 表现出来就是两页之间抖。窗口固定住以后,动画期间不再有可见性变化,帧就稳了;
* 只有读完窗口(每 TEXT_WARM_SHIFT 页)才付一次重排。
*/
const TEXT_WARM_PAGES = 12
/** 页码漂出这个范围才移动渲染窗口,避免每翻一页都改可见性触发重排。 */
const TEXT_WARM_SHIFT = 6
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
const IMG_MARK = '[img]'
@@ -54,6 +68,11 @@ const COMIC_SAVE_INTERVAL_MS = 2_000
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
const MENU_SHIFT = 'transition-transform duration-200'
/** 翻页动画时长。正文列的平移过渡,以及跟手拖拽松手后的回弹/滑完,都用这一条。 */
const PAGE_TRANSITION = 'transform 220ms ease'
/** 跟手拖拽要翻页所需的最小位移(px)。 */
const DRAG_TURN_DISTANCE = 45
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
const WHEEL_NOTCH = 40
@@ -79,6 +98,93 @@ interface ReaderParagraphBlock {
comments: ReaderContentComment[]
}
/**
* 翻页模式正文列:与页码 state 解耦。
*
* 长章(几百页 CSS 多栏)里,若每次 setPage 都把整棵段落树再调和一遍,
* 手机主线程会卡数百毫秒,表现为「松手停一下才翻到下一页」。
* 分页位移改走 contentRef 命令式写入;这里用 memo,页码变时跳过段落重渲染。
*/
const ReaderTextPageBody = memo(function ReaderTextPageBody({
blocks,
paragraphSpacing,
fontSize,
accent,
onOpenComment,
}: {
blocks: ReaderParagraphBlock[]
paragraphSpacing: number
fontSize: number
accent: string
onOpenComment: (comment: ReaderContentComment) => void
}) {
return (
<>
{blocks.map((block, key) => {
const { text, comments } = block
// content-visibility:跳过视口外段落的绘制,否则每次 transform 都要重绘整章
// (实测可到数百毫秒)。默认全部按需跳过;当前页附近的段落由
// applyParagraphWarmWindow 改成 visible 并固定成一个窗口——被跳过的段落
// 会在每帧重新判定可见性,导致动画期间反复重排(见 TEXT_WARM_PAGES)。
const skipPaint: CSSProperties = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 4em',
}
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: paragraphSpacing, textAlign: 'center', ...skipPaint }}>
<img
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
/>
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
...skipPaint,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenComment(c)
}}
title={c.label || '段评'}
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: accent,
color: accent,
fontSize: Math.max(10, fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
})}
</>
)
})
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -87,6 +193,67 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
return j === -1 ? 0 : j
}
/**
* 漫画跟手拖拽松手后「滑完这一页」。
*
* 舞台两侧各摆着相邻的一页,手指拖到哪儿舞台就平移到哪儿。松手要翻页时,
* 先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),
* 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1,
* ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵,
* 屏幕上看到的就是新页从手指离开的位置平滑归位。
*
* base 是手势开始时舞台已经带着的残余位移(上一次归位动画没滑完就又被接管)。
*/
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, base: number, turnPage: () => void): void {
const width = el.clientWidth || 1
el.style.transition = 'none'
el.style.transform = `translateX(${base + dx + turn * width}px)`
void el.offsetWidth
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translateX(0px)'
turnPage()
}
/**
* 读元素当前的实际横向位移。
*
* 跟手层/漫画舞台的归位是 CSS 过渡,一次手势可能在过渡中途就被下一次手势接管。
* 那时元素的 transform 停在中间值,必须把它读出来当作新手势的起点,
* 否则新手势从 dx 重新开始,残余位移被抹掉,画面就会跳一格——快速连续翻页时
* 最明显,看起来就是两页之间来回抖。
*/
function readTranslateX(el: HTMLElement): number {
const t = getComputedStyle(el).transform
if (!t || t === 'none') return 0
const nums = t.match(/-?[\d.]+(?:e[-+]?\d+)?/gi)
if (!nums) return 0
// translate3d 在 Chrome 里可能给 matrix(a,b,c,d,tx,ty) 也可能给 matrix3d(...),
// 两种都要取横向平移分量。
const v = nums.length >= 16 ? Number(nums[12]) : nums.length >= 6 ? Number(nums[4]) : 0
return Number.isFinite(v) ? v : 0
}
/**
* 文本滑动松手后的跟手层归位。正文多栏已瞬移,这里只把跟手层从「画面连续」的
* 位移滑回 0。不用 void offsetWidth 强刷布局——长章多栏一强制回流就要几百毫秒。
* turn=1 下一页,turn=-1 上一页;dx 为松手时的跟手位移。
*
* 起点必须是 base + dx + turn*stride:
* - stride 要和正文那一跳用的步长完全一致(列宽 + 列间距)。用列宽代替步长的话,
* 松手瞬间跟手层会少补一个列间距,画面先往回弹 48px 再往前滑。
* - base 是手势开始时跟手层已经带着的残余位移:上一次归位动画没滑完就又滑了一次时,
* 不带上它就等于把这段残余抹掉,画面跳一格。快速连续翻页时最明显。
*/
function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride: number, base: number): void {
const step = stride > 0 ? stride : dragEl.clientWidth || 1
dragEl.style.transition = 'none'
dragEl.style.transform = `translate3d(${base + dx + turn * step}px,0,0)`
requestAnimationFrame(() => {
dragEl.style.transition = 'transform 140ms ease-out'
dragEl.style.transform = 'translate3d(0,0,0)'
})
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -129,9 +296,35 @@ export default function ReaderViewPage() {
const viewportRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
/** 文本跟手层:滑动动画也写在这一层,避免给整章多栏做 transition。 */
const pageDragRef = useRef<HTMLDivElement>(null)
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
const vwRef = useRef(0)
const pageRef = useRef(0)
const pageCountRef = useRef(1)
const chapterTitleRef = useRef('')
const pageLabelRef = useRef<HTMLDivElement>(null)
const appliedTextPageRef = useRef(-1)
const appliedTextVwRef = useRef(-1)
const snapTextPageRef = useRef(false)
/** 把 pageRef 同步回 React state 的防抖定时器;翻页热路径上绝不同步 setPage。 */
const textPageSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 首次分页完成后锁住 pageCount,避免 content-visibility 改变 scrollWidth 后反复 setPageCount。 */
const pageCountLockedRef = useRef(false)
/** 正文段落节点与它们各自落在的页号,用来算「当前页附近的渲染窗口」。 */
const paragraphsRef = useRef<NodeListOf<HTMLParagraphElement> | null>(null)
const paragraphColsRef = useRef<Int32Array | null>(null)
/** 每个段落当前是不是「真实渲染」(1=visible,0=按需跳过),只在分类变化时写 DOM。 */
const paragraphWarmRef = useRef<Uint8Array | null>(null)
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
const warmCenterRef = useRef(-1)
vwRef.current = vw
pageCountRef.current = pageCount
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
@@ -170,6 +363,13 @@ export default function ReaderViewPage() {
comicDoublePage && comicSpreads.length > 0
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
/** 当前这一屏是不是并排两页(桌面端双页铺开)。 */
const comicPaired = comicDoublePage && (comicSpreads[comicSpreadIndex]?.length ?? 0) > 1
/**
* 漫画翻页模式且单页显示时接跟手拖拽:舞台左右各摆一页,手指横滑整条舞台跟着走。
* 并排两页时不接——两侧要摆的是「下一屏」而不是下一张,交给原来的滑动判定即可。
*/
const comicDragSinglePage = contentType === 'image' && settings.pageMode === 'page' && !comicPaired
// ── 漫画滚动模式的图片显示尺寸 ──
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
@@ -220,7 +420,10 @@ export default function ReaderViewPage() {
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
idx = b.dur_chapter_index
}
pendingPosRef.current = b.dur_chapter_pos ?? 0
// 位置只在「接着上次那一章读」时恢复:URL 显式跳到别的章时,b.dur_chapter_pos
// 属于另一章,套到新章上会从中间开始(音频表现为一进去就从错的时间点续播)。
const resumingSavedChapter = !explicitChapter || idx === b.dur_chapter_index
pendingPosRef.current = resumingSavedChapter ? (b.dur_chapter_pos ?? 0) : 0
// 听书:引擎是常驻的,可能已经自动播到更靠后的章,而服务端的进度还停在
// 上一次落库的位置。这种「续听」场景要以引擎的实时章节为准,否则回到本书
// 会把正在播的章节往回拽。URL 显式指定 chapter 时尊重 URL(那是明确的跳转)。
@@ -268,6 +471,10 @@ export default function ReaderViewPage() {
// 响应,换源后还可能把旧源的正文塞进新书的缓存。
const ac = new AbortController()
;(async () => {
// 每次重新取正文都先清掉上一次的报错。换源时的典型情形:服务端章节缓存刚被清空、
// 而本地 chapters 还是旧源的,这一次请求必然失败(「章节缓存为空」);等新源目录
// 落地后正文 effect 会再来一次并且成功,但不清这里的话,旧错误会一直压在正文上。
setError('')
setContent(null)
setLoadingStage('content')
try {
@@ -283,6 +490,11 @@ export default function ReaderViewPage() {
setContentType(ct.type)
setMedia(ct)
setContent(ct.content ?? '')
// 换章瞬移到目标页,不要带着上一章的翻页过渡滑过去。
snapTextPageRef.current = true
appliedTextPageRef.current = -1
pageCountLockedRef.current = false
pageRef.current = 0
setPage(0)
setComicPage(0)
setCurrentImage(0)
@@ -341,40 +553,281 @@ export default function ReaderViewPage() {
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
/** 一页正文占的横向步长:列宽 + 列间距。分页位移和跟手补位必须用同一个值。 */
const textPageStride = useCallback(() => {
const width = vwRef.current > 0
? vwRef.current
: Math.round(viewportRef.current?.getBoundingClientRect().width ?? 0)
return Math.max(width, 1) + COLUMN_GAP
}, [])
/** 把正文列平移到指定页。animate=true 时走 220ms 过渡;换章/重排传 false。 */
const applyTextPageTransform = useCallback(
(nextPage: number, animate: boolean) => {
const el = contentRef.current
if (!el) return
const stride = textPageStride()
el.style.transition = animate ? PAGE_TRANSITION : 'none'
// 正文多栏可能极宽,不要 translate3d / will-change,免得整章被提成巨大合成层。
el.style.transform = `translateX(-${nextPage * stride}px)`
appliedTextPageRef.current = nextPage
appliedTextVwRef.current = vwRef.current > 0 ? vwRef.current : (viewportRef.current?.clientWidth ?? 0)
},
[textPageStride],
)
const updateTextPageLabel = useCallback((nextPage: number) => {
const label = pageLabelRef.current
if (!label) return
const title = chapterTitleRef.current
label.textContent = title ? `${nextPage + 1} / ${pageCountRef.current} · ${title}` : `${nextPage + 1} / ${pageCountRef.current}`
}, [])
/**
* 重算「段落 → 页号」映射。正文是一条超长多栏流,段落落在哪一栏只能从布局读。
* 2500 段读一遍 offsetLeft 约 2ms(布局干净时),比让浏览器每帧自己重算便宜得多。
*/
const refreshParagraphColumns = useCallback(() => {
const el = contentRef.current
if (!el) return
const ps = el.querySelectorAll('p')
const stride = textPageStride()
const cols = new Int32Array(ps.length)
for (let i = 0; i < ps.length; i++) cols[i] = Math.floor(ps[i].offsetLeft / stride)
paragraphsRef.current = ps
paragraphColsRef.current = cols
// 映射变了,缓存的「谁在渲染」标记不能沿用:下次应用时以 DOM 现状为基准重算。
paragraphWarmRef.current = new Uint8Array(ps.length)
warmCenterRef.current = -1
}, [textPageStride])
/**
* 把当前页附近的段落固定成「真实渲染」,其余继续按需跳过。
* 只有分类真的变了的段落才写 DOM,所以窗口不动时这个函数是空转。
*/
const applyParagraphWarmWindow = useCallback(
(nextPage: number) => {
if (settings.pageMode !== 'page' || contentType !== 'text') return
const cols = paragraphColsRef.current
const ps = paragraphsRef.current
const warm = paragraphWarmRef.current
if (!cols || !ps || !warm || cols.length !== ps.length) return
const center = warmCenterRef.current
if (center >= 0 && Math.abs(nextPage - center) <= TEXT_WARM_SHIFT) return
if (center < 0) {
for (let i = 0; i < ps.length; i++) warm[i] = ps[i].style.contentVisibility === 'visible' ? 1 : 0
}
warmCenterRef.current = nextPage
const lo = nextPage - TEXT_WARM_PAGES
const hi = nextPage + TEXT_WARM_PAGES
let flipped = false
for (let i = 0; i < ps.length; i++) {
const want = cols[i] >= lo && cols[i] <= hi ? 1 : 0
if (warm[i] === want) continue
warm[i] = want
flipped = true
// 放进窗口 = visible(真实渲染),移出窗口 = auto(按需跳过,高度用记住的值)。
ps[i].style.setProperty('content-visibility', want ? 'visible' : 'auto')
}
// 可见性一变,段落高度就从估算值换成真实值,后面所有段落都会挪位,
// 缓存的映射随即过期。等下一帧布局落定再重算,别卡在这一帧里。
if (flipped) requestAnimationFrame(() => refreshParagraphColumns())
},
[settings.pageMode, contentType, refreshParagraphColumns],
)
/** 退出翻页模式时把整章段落放回按需跳过,别留着上一次的渲染窗口。 */
const resetParagraphWarmWindow = useCallback(() => {
const ps = paragraphsRef.current
const warm = paragraphWarmRef.current
if (!ps || !warm) return
for (let i = 0; i < ps.length; i++) {
if (!warm[i]) continue
warm[i] = 0
ps[i].style.setProperty('content-visibility', 'auto')
}
warmCenterRef.current = -1
}, [])
/**
* 把 pageRef 写回 React。长章每次 setPage 都会让整页重渲染卡住 1s+,
* 所以阅读翻页热路径绝不调用;只在打开菜单 / 换章 / 卸载时同步。
*/
const flushTextPageReactSync = useCallback(() => {
if (textPageSyncTimerRef.current !== null) {
clearTimeout(textPageSyncTimerRef.current)
textPageSyncTimerRef.current = null
}
setPage((p) => (p === pageRef.current ? p : pageRef.current))
}, [])
/**
* 文本翻到指定页(热路径零 setState)。
* - 正文多栏瞬移 + 页脚 DOM 更新
* - 点按/滚轮:无动画
* - 滑动松手:只动画跟手层
* dragDx 有值表示来自滑动松手。
*/
const commitTextPage = useCallback(
(nextPage: number, opts?: { dragDx?: number; dragBase?: number }) => {
const from = pageRef.current
const dragDx = opts?.dragDx
const fromSwipe = typeof dragDx === 'number'
const turn: 1 | -1 = nextPage >= from ? 1 : -1
pageRef.current = nextPage
applyTextPageTransform(nextPage, false)
updateTextPageLabel(nextPage)
applyParagraphWarmWindow(nextPage)
const dragEl = pageDragRef.current
if (fromSwipe && dragEl && nextPage !== from) {
settleTextDrag(dragEl, turn, dragDx, textPageStride(), opts?.dragBase ?? 0)
} else if (dragEl) {
// 点按翻页也可能打断上一次的归位动画。这里不能用 transition:'none' 直接抹掉残余:
// 那是硬跳。带上过渡滑回 0,起点就是当前的实际位置,画面才是连续的。
dragEl.style.transition = PAGE_TRANSITION
dragEl.style.transform = 'translate3d(0,0,0)'
}
},
[applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow, textPageStride],
)
const relayout = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
const width = vp.clientWidth
setVw(width)
const total = vp.scrollWidth
const el = contentRef.current
if (!vp || !el) return
// 列宽必须是整数、而且要和位移用的步长完全一致。
// clientWidth / state 是取整的,而实际布局宽度可能是小数(缩放、非整数 DPI 很常见,
// 比如 382.4)。容器宽 382.4、column-width 写 382 时,浏览器按容器宽排出 382.4 的列,
// 真实列距就变成 430.4,而位移按 430 算——每页差 0.4px,误差乘以页码:
// 第 339 页就偏了 135px,整页文字从句子中间切开。所以这里把容器宽钉成同一个整数,
// 真实列距就等于步长,页码再大也不会漂。
const width = Math.max(1, Math.round(vp.getBoundingClientRect().width))
const widthPx = `${width}px`
// 宽度和列宽都用命令式写:React 那边用的是 vw state,晚一帧才跟上,
// 这一帧里算出来的 scrollWidth / 页数就会是错的。
if (el.style.width !== widthPx) el.style.width = widthPx
if (el.style.columnWidth !== widthPx) el.style.columnWidth = widthPx
// 用正文列自己的 scrollWidth;视口在子元素 transform 时 scrollWidth 会抖,
// 导致翻一页就重新 setPageCount → 整页重渲染卡 1s+。
const total = el.scrollWidth ?? vp.scrollWidth
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
setPageCount(count)
setPage((p) => {
if (pendingEndRef.current && count > 0) {
pendingEndRef.current = false
return count - 1
}
if (pendingPosRef.current > 0) {
const pos = Math.min(pendingPosRef.current, count - 1)
pendingPosRef.current = 0
return pos
}
return Math.min(p, count - 1)
})
}, [])
const widthChanged = width !== vwRef.current
if (widthChanged) pageCountLockedRef.current = false
// content-visibility 只给视口附近的段落算真尺寸,远端的段落按估算高度参与分页,
// 所以 scrollWidth 会随阅读单向变大。锁定期内只接受变大(页数更准,且增长是单向
// 的,不会来回抖);不接受变小——正文一跳回估算值就会把页码夹回上一页,
// 表现出来就是「翻到下一页又被拉回上一页」。
const effectiveCount = pageCountLockedRef.current ? Math.max(count, pageCountRef.current) : count
const countGrew = effectiveCount !== pageCountRef.current
const pendingEnd = pendingEndRef.current && effectiveCount > 0
const pendingPos = pendingPosRef.current > 0
const clamped = Math.min(pageRef.current, Math.max(0, effectiveCount - 1))
const nextPage = pendingEnd
? effectiveCount - 1
: pendingPos
? Math.min(pendingPosRef.current, effectiveCount - 1)
: clamped
if (pendingEnd) pendingEndRef.current = false
if (pendingPos) pendingPosRef.current = 0
const pageChanged = nextPage !== pageRef.current
// 热路径上尺寸没变就别 setState——否则每次翻页都会重渲染 2000+ 段正文。
if (!widthChanged && !countGrew && !pageChanged && !pendingEnd && !pendingPos) {
if (count > 1) pageCountLockedRef.current = true
return
}
vwRef.current = width
pageCountRef.current = effectiveCount
pageRef.current = nextPage
applyParagraphWarmWindow(nextPage)
if (widthChanged) setVw(width)
if (countGrew) {
setPageCount(effectiveCount)
if (effectiveCount > 1) pageCountLockedRef.current = true
}
if (pageChanged || pendingEnd || pendingPos) {
snapTextPageRef.current = true
setPage(nextPage)
updateTextPageLabel(nextPage)
applyTextPageTransform(nextPage, false)
}
}, [applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow])
useLayoutEffect(() => {
relayout()
}, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw])
// 正文/字号/列宽变了:段落落位全变了,重新算映射并重建渲染窗口。
if (settings.pageMode === 'page' && contentType === 'text') {
refreshParagraphColumns()
applyParagraphWarmWindow(pageRef.current)
} else {
resetParagraphWarmWindow()
}
}, [relayout, refreshParagraphColumns, applyParagraphWarmWindow, resetParagraphWarmWindow, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, contentType, vw])
// 页脚页码的文字只由 DOM 直接写(翻页热路径不 setState),React 那边不渲染子节点。
// 两边都写的话,React 重渲染时会拿滞后的 page 去覆盖,覆盖失败时页面会闪一下旧页码。
// 这里在换章 / 页数变化时补写一次,保证页脚不为空。
useLayoutEffect(() => {
if (settings.pageMode !== 'page' || contentType !== 'text') return
updateTextPageLabel(pageRef.current)
}, [content, pageCount, chapterIndex, contentType, settings.pageMode, updateTextPageLabel])
// 列宽变化或换章恢复时对齐位移。
// 阅读热路径以 pageRef 为权威页码(不走 setPage);这里绝不能用滞后的 React page
// 反写 pageRef,否则会出现「翻到下一页又被拉回上一页」的来回抖动。
useLayoutEffect(() => {
if (contentType !== 'text' || settings.pageMode !== 'page') return
if (snapTextPageRef.current) {
snapTextPageRef.current = false
pageRef.current = page
applyTextPageTransform(page, false)
updateTextPageLabel(page)
return
}
// React page 还没跟上热路径:只在宽度变了时按 pageRef 重算位移。
if (page !== pageRef.current) {
if (appliedTextVwRef.current !== vw) {
applyTextPageTransform(pageRef.current, false)
updateTextPageLabel(pageRef.current)
}
return
}
if (appliedTextPageRef.current === page && appliedTextVwRef.current === vw) return
applyTextPageTransform(page, false)
updateTextPageLabel(page)
}, [page, vw, contentType, settings.pageMode, applyTextPageTransform, updateTextPageLabel])
useEffect(() => {
const vp = viewportRef.current
if (!vp) return
const ro = new ResizeObserver(() => relayout())
// 合并同一帧里多次 resize,避免字体/滚动条抖动连带 relayout→setState。
let raf = 0
const ro = new ResizeObserver(() => {
if (raf) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = 0
relayout()
})
})
ro.observe(vp)
return () => ro.disconnect()
return () => {
if (raf) cancelAnimationFrame(raf)
ro.disconnect()
}
}, [relayout, content, settings.pageMode])
// 打开菜单时把 pageRef 写回 state,进度条才不会停在旧页。
useEffect(() => {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// 滚动模式恢复进度
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
@@ -408,13 +861,22 @@ export default function ReaderViewPage() {
[book, chapterIndex, chapters],
)
// 文本翻页进度:不依赖 React page(翻页热路径不 setPage),定时读 pageRef。
useEffect(() => {
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
const t = setTimeout(() => savePos(page), 1500)
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
let last = -1
const tick = () => {
const pos = pageRef.current
if (pos === last) return
last = pos
savePos(pos)
}
const t = setInterval(tick, 2000)
return () => {
clearInterval(t)
tick()
}
}, [content, chapterIndex, settings.pageMode, contentType, savePos])
// 漫画单页进度保存
useEffect(() => {
@@ -553,6 +1015,12 @@ export default function ReaderViewPage() {
await readerAPI.switchOrigin(book.id, origin)
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
contentCache.current.clear()
// 换源后服务端已清空章节缓存,而本地的 chapters 还是旧源的目录:不清理的话,
// 重新加载书籍期间(listBooks 已返回、新目录还没抓回来)正文 effect 会拿旧目录
// 去打新源,撞上「章节缓存为空」。这里先把正文/目录清空,等新源目录到位再取。
setError('')
setContent(null)
setChapters([])
setReloadKey((v) => v + 1)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
@@ -586,9 +1054,10 @@ export default function ReaderViewPage() {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page > 0) setPage((p) => p - 1)
// 文本翻页读 pageRef:动画期间 React page 可能还没跟上,连点不能被旧 state 卡住。
if (pageRef.current > 0) commitTextPage(pageRef.current - 1)
else goChapter(-1, true)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, goChapter, commitTextPage])
const goNext = useCallback(() => {
if (contentType === 'audio') {
@@ -612,15 +1081,105 @@ export default function ReaderViewPage() {
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page < pageCount - 1) setPage((p) => p + 1)
if (pageRef.current < pageCountRef.current - 1) commitTextPage(pageRef.current + 1)
else goChapter(1)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, goChapter, commitTextPage])
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
//
// 手机上再补一层横向滑动:从左往右滑=上一页、从右往左滑=下一页,并且跟手——
// 滑动途中内容就跟着手指走,松手再决定翻页还是回弹(判定、跟手位移与
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
//
// 跟手拖拽平移的是两个不同的元素:
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。正文多栏只瞬移,
// 滑页动画全在跟手层(settleTextDrag);长章不能给多栏做 transition。
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
// 相抵,画面看不出切换。
const comicStageRef = useRef<HTMLDivElement>(null)
const pageDragEnabled = settings.pageMode === 'page' && (contentType === 'text' || comicDragSinglePage)
/**
* 本次跟手手势开始时,跟手层/舞台已经带着的残余位移。
* 上一次翻页的归位动画没滑完就又滑了一次,新位移必须接着它算,否则画面跳一格。
*/
const dragBaseRef = useRef(0)
const onPageDragStart = useCallback(() => {
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
dragBaseRef.current = el ? readTranslateX(el) : 0
}, [contentType])
const onPageDrag = useCallback(
(dx: number) => {
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
if (!el) return
el.style.transition = 'none'
el.style.transform = `translate3d(${dragBaseRef.current + dx}px,0,0)`
},
[contentType],
)
const onPageDragEnd = useCallback(
(dx: number) => {
const base = dragBaseRef.current
dragBaseRef.current = 0
if (contentType === 'image') {
const el = comicStageRef.current
if (!el) return
if (dx <= -DRAG_TURN_DISTANCE) {
settleComicDrag(el, 1, dx, base, goNext)
return
}
if (dx >= DRAG_TURN_DISTANCE) {
settleComicDrag(el, -1, dx, base, goPrev)
return
}
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translateX(0px)'
return
}
const el = pageDragRef.current
if (!el) return
if (dx <= -DRAG_TURN_DISTANCE) {
if (pageRef.current < pageCountRef.current - 1) {
commitTextPage(pageRef.current + 1, { dragDx: dx, dragBase: base })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(1)
}
return
}
if (dx >= DRAG_TURN_DISTANCE) {
if (pageRef.current > 0) {
commitTextPage(pageRef.current - 1, { dragDx: dx, dragBase: base })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(-1, true)
}
return
}
// 位移不够:回弹,不翻页。
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
},
[contentType, goNext, goPrev, commitTextPage, goChapter],
)
const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
onSwipeRight: goPrev,
onSwipeLeft: goNext,
minDistance: DRAG_TURN_DISTANCE,
onDragStart: pageDragEnabled ? onPageDragStart : undefined,
onDrag: pageDragEnabled ? onPageDrag : undefined,
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
})
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
@@ -699,6 +1258,7 @@ export default function ReaderViewPage() {
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
chapterTitleRef.current = currentChapter?.title ?? ''
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
@@ -745,7 +1305,10 @@ export default function ReaderViewPage() {
1000,
),
onChange: (v: number) => {
if (settings.pageMode === 'page') setPage(v - 1)
if (settings.pageMode === 'page') {
commitTextPage(v - 1)
flushTextPageReactSync()
}
else {
const el = scrollRef.current
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
@@ -798,62 +1361,13 @@ export default function ReaderViewPage() {
[book],
)
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
const { text, comments } = block
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
<img
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
/>
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: settings.paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
// 点气泡会顺带翻页或呼出菜单。
e.stopPropagation()
void openComment(c)
}}
title={c.label || '段评'}
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: theme.accent,
color: theme.accent,
fontSize: Math.max(10, settings.fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
}
// 段评点击:ReaderTextPageBody 要稳定回调,否则 memo 每次都失效。
const handleOpenComment = useCallback(
(comment: ReaderContentComment) => {
void openComment(comment)
},
[openComment],
)
// ── 渲染 ──
if (error && !book) {
@@ -888,6 +1402,8 @@ export default function ReaderViewPage() {
page={comicPage}
imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
draggable={comicDragSinglePage}
stageRef={comicStageRef}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
}}
@@ -913,20 +1429,27 @@ export default function ReaderViewPage() {
style={menuShiftStyle}
>
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
className="h-full"
style={{
columnWidth: `${Math.max(vw, 1)}px`,
columnGap: `${COLUMN_GAP}px`,
columnFill: 'auto',
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
transition: 'transform 220ms ease',
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
{blocks.map((b, i) => renderParagraph(b, i))}
{/* 跟手层:跟手位移写这里;分页平移由 applyTextPageTransform 写在 contentRef 上。 */}
<div ref={pageDragRef} className="h-full">
<div
ref={contentRef}
className="h-full"
style={{
columnWidth: `${Math.max(vw, 1)}px`,
columnGap: `${COLUMN_GAP}px`,
columnFill: 'auto',
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
<ReaderTextPageBody
blocks={blocks}
paragraphSpacing={settings.paragraphSpacing}
fontSize={settings.fontSize}
accent={theme.accent}
onOpenComment={handleOpenComment}
/>
</div>
</div>
</div>
</div>
@@ -950,7 +1473,13 @@ export default function ReaderViewPage() {
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
{blocks.map((b, i) => renderParagraph(b, i))}
<ReaderTextPageBody
blocks={blocks}
paragraphSpacing={settings.paragraphSpacing}
fontSize={settings.fontSize}
accent={theme.accent}
onOpenComment={handleOpenComment}
/>
</div>
</div>
)}
@@ -992,24 +1521,52 @@ export default function ReaderViewPage() {
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<div
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>
<button
type="button"
aria-label="上一页"
onClick={() => {
if (consumeSwipe()) return
goPrev()
}}
className="cursor-w-resize"
/>
<button
type="button"
aria-label="菜单"
onClick={() => setMenuOpen((v) => !v)}
onClick={() => {
if (consumeSwipe()) return
setMenuOpen((v) => !v)
}}
className="cursor-default"
/>
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
<button
type="button"
aria-label="下一页"
onClick={() => {
if (consumeSwipe()) return
goNext()
}}
className="cursor-e-resize"
/>
</div>
)}
</div>
{/* 页脚页码(翻页模式) */}
{/* 页脚页码(翻页模式)。文字由 updateTextPageLabel 直接写 DOM,
React 不渲染子节点,避免重渲染时用滞后的页码覆盖。 */}
{settings.pageMode === 'page' && contentType === 'text' && content !== null && (
<div className="pointer-events-none pb-2 text-center text-2xs opacity-50" style={{ color: theme.text }}>
{page + 1} / {pageCount} · {currentChapter?.title ?? ''}
</div>
<div
ref={pageLabelRef}
className="pointer-events-none pb-2 text-center text-2xs opacity-50"
style={{ color: theme.text }}
/>
)}
{/* 主菜单(仿 legado ReadMenu) */}
@@ -0,0 +1,46 @@
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader.ts'
import { pickChapterIndex } from './bookDetailModel.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`bookDetailModel: ${name}`)
}
function cached(index: number, url: string): ReaderChapter {
return { index, title: `第${index}章`, url, is_volume: false }
}
function toc(index: number, url: string, over: Partial<ReaderTocChapter> = {}): ReaderTocChapter {
return { index, title: `第${index}章`, url, is_volume: false, update_time: '', ...over }
}
// 目录一致(正常情况):按 url 定位到的序号与现抓序号相同
{
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
check('same url keeps the same index', pickChapterIndex(list, toc(2, 'https://s/3')) === 2)
}
// 站点在开头插了新章:同一章在现抓目录里是 2,在缓存目录里是 1——必须用缓存里的 1,
// 照搬现抓的 2 会跳到下一章。
{
const list = [cached(0, 'https://s/1'), cached(1, 'https://s/2'), cached(2, 'https://s/3')]
check('inserted chapter maps back to the cached index', pickChapterIndex(list, toc(2, 'https://s/2')) === 1)
}
// 缓存目录里没有这一章(站点改版、或两边取的不是同一份目录):退回现抓的序号
{
const list = [cached(0, 'https://s/1')]
check('unknown url falls back to the fetched index', pickChapterIndex(list, toc(7, 'https://s/7')) === 7)
}
// 缓存为空(第一次打开这本书):退回现抓序号,阅读器随后会把这份目录存下来
{
check('empty cache falls back to the fetched index', pickChapterIndex([], toc(5, 'https://s/5')) === 5)
}
// 章节没有 url(拿不到地址):退回现抓序号,不做 url 匹配
{
const list = [cached(0, ''), cached(1, '')]
check('chapter without url falls back to the fetched index', pickChapterIndex(list, toc(1, '')) === 1)
}
console.log('bookDetailModel.test.ts ok')
+20
View File
@@ -0,0 +1,20 @@
// 书籍详情页点目录跳读的纯逻辑(不依赖 React,便于单测)。
import type { ReaderChapter, ReaderTocChapter } from '../../api/reader'
/**
* 把详情页点中的章节换算成阅读器要的序号。
*
* 阅读器打开一本书时优先用库里缓存的目录(`/books/:id/chapters`),而详情页显示的
* 目录是现抓的:站点更新过目录(插入或调整过章节)时两者会错位,直接照搬现抓目录
* 里的序号会跳到别的章。url 是章节的稳定标识,所以先按 url 在缓存目录里定位;
* 找不到(缓存为空,或两边取的根本不是同一份目录)才退回现抓的序号——缓存为空时
* 阅读器本来就会把这次现抓的目录存下来,序号自然对得上。
*/
export function pickChapterIndex(cached: ReaderChapter[], chapter: ReaderTocChapter): number {
if (chapter.url) {
const hit = cached.findIndex((c) => c.url === chapter.url)
if (hit >= 0) return hit
}
return chapter.index
}
+144 -2
View File
@@ -64,6 +64,14 @@ class ReaderAudioEngine {
private detaching = false
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
/** 预取在途的章序号,避免同一章重复请求。 */
private prefetching = new Set<number>()
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
private warmer: HTMLAudioElement | null = null
/** warmer 当前装载的音轨,避免重复赋值。 */
private warmedTrack = ''
constructor() {
const audio = new Audio()
@@ -93,7 +101,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,
@@ -128,6 +142,9 @@ class ReaderAudioEngine {
}
void this.attachSource(input.track)
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex !== null) this.prefetchChapter(nextIndex)
}
play(): void {
@@ -222,6 +239,7 @@ class ReaderAudioEngine {
stop(): void {
this.commitProgress()
this.detachSource()
this.clearPrefetch()
this.timerDeadline = 0
this.pausedAt = 0
// 下一次开播重新套用「上次的定时设置」,否则关掉再听一本书时定时不会自动开
@@ -242,12 +260,22 @@ class ReaderAudioEngine {
private async attachSource(src: string): 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
}
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
if (!this.audio.paused) this.audio.pause()
this.setSessionMetadata(src)
if (src.includes('.m3u8')) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
@@ -270,6 +298,7 @@ class ReaderAudioEngine {
this.audio.src = src
}
/** 彻底卸载音源(停止播放时用)。换章不走这里,见 attachSource 的说明。 */
private detachSource(): void {
this.detaching = true
this.hls?.destroy()
@@ -279,9 +308,117 @@ 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 })
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
// 避免用户中途停下时白下整章的流量。
if (ct.transcoding) this.warmNextBytes(track)
})
.catch(() => undefined)
.finally(() => this.prefetching.delete(index))
}
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
private warmNextBytes(track: string): void {
// HLS 交给 hls.js 按需拉流,二次下载没有意义
if (!track || track.includes('.m3u8') || 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)
}
/** 用预取结果接续下一章;命中返回 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,
transcoding: hit.transcoding,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
initialPos: 0,
})
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
if (!hit.track.includes('.m3u8')) 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 +481,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 +530,7 @@ class ReaderAudioEngine {
this.checkCreditsEnd()
// 后台播放时 timeupdate 仍会触发,这是「到点暂停」最可靠的位置
this.checkTimer()
this.maybeWarmNext()
})
audio.addEventListener('error', () => {
+109
View File
@@ -0,0 +1,109 @@
import type { ReaderSearchBook, ReaderSearchOrigin } from '../api/reader.ts'
import { mergeSearchBooks, mergeSearchSkipped, searchBookKey } from './searchBooks.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`searchBooks: ${name}`)
}
function origin(sourceId: string, bookUrl: string, originName = sourceId): ReaderSearchOrigin {
return { source_id: sourceId, origin: `https://${sourceId}.example.com`, origin_name: originName, origin_type: 0, book_url: bookUrl, latest_chapter: '' }
}
function book(over: Partial<ReaderSearchBook> & { name: string; author: string }): ReaderSearchBook {
return {
kind: '',
word_count: '',
latest_chapter: '',
intro: '',
cover_url: '',
book_url: '',
origins: [],
...over,
}
}
// 聚合键:作者缺省时只用书名(和后端 name|author 的拼法一致)
check('key uses name and author', searchBookKey('全球高武', '懒人') === '全球高武|懒人')
check('key falls back to name without author', searchBookKey('全球高武', '') === '全球高武')
check('key trims blanks', searchBookKey(' 全球高武 ', ' 懒人 ') === '全球高武|懒人')
check('key treats blank author as missing', searchBookKey('全球高武', ' ') === '全球高武')
// 跨页去重:不支持分页的源在每一页都返回首页结果,不能重复追加一条书
{
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: 'http://c/1.jpg' })]
const page2 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
const merged = mergeSearchBooks(page1, page2)
check('duplicate across pages is merged', merged.length === 1)
check('duplicate origin is not re-added', merged[0].origins.length === 1)
check('existing fields survive the merge', merged[0].cover_url === 'http://c/1.jpg')
}
// 新一页的书接在已有书后面,顺序不倒
{
const page1 = [book({ name: '甲', author: '作者', origins: [origin('a', '/a')] })]
const page2 = [book({ name: '乙', author: '作者', origins: [origin('a', '/b')] })]
const merged = mergeSearchBooks(page1, page2)
check('new page appends', merged.length === 2)
check('first page keeps its position', merged[0].name === '甲')
check('second page follows', merged[1].name === '乙')
}
// 同书多源:书源并进同一项(决定「N 源可换」),且不会补齐覆盖已有的有效值
{
const page1 = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')], cover_url: '', intro: '第一页的简介' })]
const page2 = [
book({
name: '全球高武',
author: '懒人',
origins: [origin('b', '/2')],
cover_url: 'http://c/2.jpg',
intro: '第二页的简介',
kind: '玄幻',
}),
]
const merged = mergeSearchBooks(page1, page2)
check('same book merges origins', merged[0].origins.length === 2)
check('missing cover is filled', merged[0].cover_url === 'http://c/2.jpg')
check('missing kind is filled', merged[0].kind === '玄幻')
check('existing intro is not overwritten', merged[0].intro === '第一页的简介')
}
// 不同作者的同名书不能被合并成一条
{
const merged = mergeSearchBooks(
[book({ name: '同名书', author: '甲', origins: [origin('a', '/1')] })],
[book({ name: '同名书', author: '乙', origins: [origin('a', '/2')] })],
)
check('same name different author stays separate', merged.length === 2)
}
// 无名条目没有聚合键,直接丢弃(后端也会跳过 Name 为空的项)
{
const merged = mergeSearchBooks([], [book({ name: ' ', author: '作者' })])
check('nameless item is dropped', merged.length === 0)
}
// 不原地改动上一份状态:prev 的数组与对象都不能被写坏
{
const prev = [book({ name: '全球高武', author: '懒人', origins: [origin('a', '/1')] })]
const snapshot = JSON.stringify(prev)
mergeSearchBooks(prev, [book({ name: '全球高武', author: '懒人', origins: [origin('b', '/2')] })])
check('prev state is not mutated', JSON.stringify(prev) === snapshot)
}
// 失败书源按 source_id 去重,原因取最新一页的
{
const merged = mergeSearchSkipped(
[{ source_id: 'a', origin_name: '源A', reason: '超时' }],
[
{ source_id: 'a', origin_name: '源A', reason: '限流' },
{ source_id: 'b', origin_name: '源B', reason: '解析失败' },
],
)
check('skipped is deduped by source', merged.length === 2)
check('skipped reason comes from the newest page', merged[0].reason === '限流')
check('new failed source is appended', merged[1].source_id === 'b')
check('empty incoming keeps the same array', mergeSearchSkipped(merged, []) === merged)
}
console.log('searchBooks.test.ts ok')
+84
View File
@@ -0,0 +1,84 @@
import type { ReaderSearchBook, ReaderSearchOrigin, ReaderSearchSkipped } from '../api/reader'
/**
* 同名同作者的聚合键——与后端 mergeSearchResults 的 `name|author` 一致:
* 同一本书多源命中时合并成一行;作者缺省时只用书名(否则作者为空的源会被拆成两条)。
*/
export function searchBookKey(name: string, author: string): string {
const n = name.trim()
const a = author.trim()
return a ? `${n}|${a}` : n
}
/** 两条命中书源是否指向同一个源的同一个地址(跨页重复返回时用于去重)。 */
function sameOrigin(a: ReaderSearchOrigin, b: ReaderSearchOrigin): boolean {
return a.source_id === b.source_id && a.book_url === b.book_url
}
/** 补齐缺失字段(对应后端 fillMissingSearchBookFields):先到的空值不能挡掉后面源的有效值。 */
function fillMissing(dst: ReaderSearchBook, src: ReaderSearchBook): void {
if (!dst.cover_url) dst.cover_url = src.cover_url
if (!dst.intro) dst.intro = src.intro
if (!dst.kind) dst.kind = src.kind
if (!dst.word_count) dst.word_count = src.word_count
if (!dst.latest_chapter) dst.latest_chapter = src.latest_chapter
}
/**
* 把新一页的搜索结果并入已加载的列表(滚动加载用)。
*
* 每一页都是后端独立聚合出来的,跨页去重只能在前端做:书源 searchUrl 里没有
* {{page}} 时它会重复返回首页结果,同一个源的同一本书就可能在每一页都出现,
* 直接 append 会让列表堆满重复行。这里按 searchBookKey 合并成一项:
* 并书源(决定「N 源可换」)、补缺失字段;顺序保持「先到的在前」,
* 于是第 2 页的新书接在第 1 页后面。
*
* 返回新数组与新对象,调用方可以直接交给 setState,不必担心原地改动上一份状态。
*/
export function mergeSearchBooks(prev: ReaderSearchBook[], incoming: ReaderSearchBook[]): ReaderSearchBook[] {
const out = prev.map((b) => ({ ...b, origins: [...b.origins] }))
const byKey = new Map<string, ReaderSearchBook>()
for (const b of out) byKey.set(searchBookKey(b.name, b.author), b)
for (const b of incoming) {
// 无名条目没有可用的聚合键(后端也会跳过 Name 为空的项),直接丢弃。
if (!b.name.trim()) continue
const key = searchBookKey(b.name, b.author)
const existing = byKey.get(key)
if (!existing) {
const copy: ReaderSearchBook = { ...b, origins: [...b.origins] }
out.push(copy)
byKey.set(key, copy)
continue
}
fillMissing(existing, b)
for (const origin of b.origins) {
if (!existing.origins.some((o) => sameOrigin(o, origin))) existing.origins.push(origin)
}
}
return out
}
/**
* 合并搜索失败的书源列表(滚动加载时每页失败的源可能不同)。
* 按 source_id 去重,原因是「任何一页失败过就算失败」——排查页面关心的是哪些源
* 有问题,而不是它这一页恰好成功;同一个源在多页里原因不同时取最新一页的。
*/
export function mergeSearchSkipped(
prev: ReaderSearchSkipped[],
incoming: ReaderSearchSkipped[],
): ReaderSearchSkipped[] {
if (incoming.length === 0) return prev
const out = [...prev]
const index = new Map(out.map((s, i) => [s.source_id, i]))
for (const s of incoming) {
const at = index.get(s.source_id)
if (at === undefined) {
index.set(s.source_id, out.length)
out.push(s)
} else {
out[at] = s
}
}
return out
}
+6
View File
@@ -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: {