Compare commits

...

12 Commits

Author SHA1 Message Date
truewhile 374ee28f01 添加点击2次全屏的功能 2026-10-10 09:54:01 +08:00
truewhile 3a6b8db0b3 添加全屏功能 2026-10-08 23:59:35 +08:00
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
33 changed files with 2826 additions and 169 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))
+5
View File
@@ -95,11 +95,16 @@
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)**
@@ -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()})
}
+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) {
+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 {
+26
View File
@@ -13,6 +13,7 @@ import (
"io"
"net/http"
"net/url"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -404,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 {
@@ -641,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 {
@@ -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()
}
+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 表示该请求依赖当前阶段不支持的能力,
+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)
+29
View File
@@ -0,0 +1,29 @@
import { decideDoubleTap } from './useDoubleTap.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useDoubleTap: ${name}`)
}
// 空闲时的第一下:进入等待窗口,单击不立刻触发(留给定时器)。
const first = decideDoubleTap('none', 'tap')
check('first tap opens a window', first.window === 'tap')
check('first tap does not fire immediately', first.fire === null)
// 窗口里的第二下:判成双击并立刻触发(必须在事件里同步执行,见实现注释)。
const second = decideDoubleTap('tap', 'tap')
check('second tap fires double', second.fire === 'double')
check('second tap closes the window', second.window === 'none')
// 菜单遮罩关菜单:只开窗口,不触发单击,也不影响状态语义。
const armed = decideDoubleTap('none', 'arm')
check('arm opens a window', armed.window === 'armed')
check('arm never fires a single', armed.fire === null)
// 关掉菜单后紧接的第二下要算双击,否则菜单一开一关就永远进不了全屏。
check('tap after arm fires double', decideDoubleTap('armed', 'tap').fire === 'double')
// 已经在双击窗口里再 arm:续期,窗口保持 armed。
check('arm during window stays armed', decideDoubleTap('armed', 'arm').window === 'armed')
check('arm during tap window stays armed', decideDoubleTap('tap', 'arm').window === 'armed')
console.log('useDoubleTap.test.ts ok')
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef } from 'react'
/** 两次点击间隔在这个毫秒数以内算一次双击。 */
export const DOUBLE_TAP_MS = 280
/**
* 双击窗口的状态。
*
* - `none` 空闲,没有待判定的点击
* - `tap` 刚单击过一次,还等第二下;窗口期满仍没有第二下就触发单击
* - `armed` 只开了双击窗口(菜单遮罩关掉菜单时用),期满什么也不触发
*/
export type DoubleTapWindow = 'none' | 'tap' | 'armed'
/** 一次动作立刻要触发的效果;null 表示只更新窗口状态、不立刻触发(留给定时器)。 */
export type DoubleTapFire = 'single' | 'double' | null
export interface DoubleTapDecision {
window: DoubleTapWindow
fire: DoubleTapFire
}
/**
* 双击窗口的纯决策(不碰计时器,便于单测):给定当前窗口状态和这次动作,
* 得出窗口进入什么状态、要不要立刻触发回调。
*
* `tap` = 用户点了一下中间区域;`arm` = 只开窗口不算点击(关菜单时调用)。
*/
export function decideDoubleTap(window: DoubleTapWindow, action: 'tap' | 'arm'): DoubleTapDecision {
if (action === 'arm') {
// 只开窗口:已在窗口里就续期,否则新开一个 armed 窗口,永远不触发单击。
return { window: 'armed', fire: null }
}
// 已经有窗口(无论是单击待定的 tap 还是刚 arm 的)→ 第二下到达,判成双击。
if (window !== 'none') return { window: 'none', fire: 'double' }
// 第一下:进入 tap 窗口等第二下,单击由窗口期满的定时器触发。
return { window: 'tap', fire: null }
}
/**
* 中间区域的「单击 / 双击」识别(手机上用来区分呼出菜单和进出全屏)。
*
* 中间区域原先单击就是呼出菜单,现在双击还要切全屏,两个动作落在同一块屏幕上,
* 就得先等一小会儿看有没有第二下:第一下把窗口置为 `tap`,第二下在窗口内到达就
* 取消单击、改判双击。**双击回调必须在第二次点击的事件里同步执行**——浏览器只认
* 用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。
*
* 返回三个入口:
* - `tap()`:分区点击。`enabled` 为假(桌面端,或 iOS 这种不支持元素全屏的浏览器)
* 时不做任何延迟,单击立刻生效,端上呼出菜单的手感与改动前完全一致。
* - `arm()`:只开一个双击窗口、不排单击。菜单打开时整屏遮罩会先吃掉那次点击
* (见 ReaderViewPage 的关闭遮罩),遮罩把菜单关掉的同时调它,紧跟着的第二下
* 才能落进窗口被判成双击,而不是又当成单击把菜单重新打开。
* - `cancel()`:丢掉待判定的点击。用户点了中间又立刻去翻页时,别让那次单击
* 在窗口期满时把菜单弹出来。
*/
export function useDoubleTap({
onDoubleTap,
onSingleTap,
enabled,
delay = DOUBLE_TAP_MS,
}: {
onDoubleTap: () => void
onSingleTap: () => void
enabled: boolean
delay?: number
}): { tap: () => void; arm: () => void; cancel: () => void } {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const windowRef = useRef<DoubleTapWindow>('none')
// 回调每次渲染都可能是新闭包,用 ref 兜住,免得已排队的定时器跟着旧闭包一起失效。
const doubleRef = useRef(onDoubleTap)
const singleRef = useRef(onSingleTap)
useEffect(() => {
doubleRef.current = onDoubleTap
singleRef.current = onSingleTap
})
const clear = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
/** 取消待判定的点击并回到空闲。 */
const cancel = useCallback(() => {
clear()
windowRef.current = 'none'
}, [clear])
/** 开一个双击窗口:期满后,tap 窗口触发单击,armed 窗口什么也不做。 */
const startWindow = useCallback(
(kind: DoubleTapWindow) => {
clear()
windowRef.current = kind
timerRef.current = setTimeout(() => {
timerRef.current = null
const wasTap = windowRef.current === 'tap'
windowRef.current = 'none'
if (wasTap) singleRef.current()
}, delay)
},
[clear, delay],
)
// 卸载时清掉待执行的单击,避免离开页面后还蹦一下。
useEffect(() => cancel, [cancel])
// 双击被关掉(如切到桌面布局)时,丢弃已排队的点击。
useEffect(() => {
if (!enabled) cancel()
}, [enabled, cancel])
const tap = useCallback(() => {
if (!enabled) {
singleRef.current()
return
}
const decision = decideDoubleTap(windowRef.current, 'tap')
if (decision.fire === 'double') {
// 第二下:取消待定的单击,改判双击(同步执行,保住用户手势上下文)。
cancel()
doubleRef.current()
return
}
startWindow(decision.window)
}, [enabled, cancel, startWindow])
const arm = useCallback(() => {
if (!enabled) return
startWindow(decideDoubleTap(windowRef.current, 'arm').window)
}, [enabled, startWindow])
return { tap, arm, cancel }
}
+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 }
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
// 按设备能力判断「手机/平板」:没有真正的鼠标悬停能力的就是触摸设备。
// 应用里已有同样的做法(PlayerControls / Vr360Stage 的 isHoverlessDevice),
// 这里做成 hook:媒体查询在字体缩放、外接鼠标插拔、chrome 设备模拟时会变化,
// 跟随 change 事件回写,比渲染时读一次更稳。
const HOVERLESS_QUERY = '(hover: none)'
function queryHoverless(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return window.matchMedia(HOVERLESS_QUERY).matches
}
/** 当前设备是否是触摸设备(手机/平板)。桌面端(带鼠标)返回 false。 */
export function useIsTouchDevice(): boolean {
const [touch, setTouch] = useState(queryHoverless)
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
const media = window.matchMedia(HOVERLESS_QUERY)
const onChange = () => setTouch(media.matches)
onChange()
media.addEventListener('change', onChange)
return () => media.removeEventListener('change', onChange)
}, [])
return touch
}
+106 -11
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(
+49 -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>
)
}
@@ -212,8 +252,10 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
if (x < 0.3) {
onZone('left')
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
} else if (x > 0.7) {
onZone('right')
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
} else {
onZone('center')
File diff suppressed because it is too large Load Diff