Compare commits

..

133 Commits

Author SHA1 Message Date
truewhile 08d521b65d Merge changes from prometheus/mebox-40a7378b 2026-11-12 22:38:54 +08:00
truewhile 5806839601 Merge changes from prometheus/mebox-40a7378b 2026-11-12 22:13:57 +08:00
truewhile d7c150ffd9 优化 2026-11-12 18:58:45 +08:00
truewhile 79943d9067 优化 2026-11-05 17:03:26 +08:00
truewhile 9ffac320c4 优化 2026-11-04 10:16:12 +08:00
truewhile 9b8ca33279 bug处理 2026-11-01 09:23:58 +08:00
truewhile 34e61a5994 优化 2026-10-31 12:48:50 +08:00
truewhile d91ded9d55 优化 2026-10-30 18:54:26 +08:00
truewhile 8458b8060a 优化 2026-10-30 10:45:59 +08:00
truewhile 623d87f12b 优化 2026-10-29 23:47:18 +08:00
truewhile d820214abc 优化 2026-10-29 22:52:39 +08:00
truewhile 0ab4693b92 优化 2026-10-29 22:11:28 +08:00
truewhile db516ded61 优化 2026-10-29 00:09:00 +08:00
truewhile 416d4c8c3b 优化 2026-10-27 22:14:40 +08:00
truewhile 78cfe0aba2 优化 2026-10-24 13:51:25 +08:00
truewhile 60ed34d5b1 优化 2026-10-24 00:22:10 +08:00
truewhile 31e775b9ba 优化 2026-10-24 00:12:20 +08:00
truewhile bf35bd3d02 优化 2026-10-23 23:11:05 +08:00
truewhile 0a84031d06 fix(player): 方向键不再唤出控制栏,仅续期隐藏计时器
方向键调整进度/音量时,若控制栏隐藏则保持隐藏,若可见则只刷新自动隐藏计时器,避免连续按键中途消失。
2026-10-22 17:11:02 +08:00
truewhile 9ccf60427a 优化 2026-10-22 15:14:00 +08:00
truewhile 54b0e33968 优化 2026-10-22 00:44:37 +08:00
truewhile 74487359bf 优 2026-10-21 13:21:13 +08:00
truewhile 55854a0adb 优化播放逻辑 2026-10-21 12:02:11 +08:00
truewhile 121a0a3cc5 优化 2026-10-21 10:11:47 +08:00
truewhile 3057664a48 优化 2026-10-21 09:19:14 +08:00
truewhile 7793d50906 优化 2026-10-21 00:06:16 +08:00
truewhile 5ad71b9421 优化查询 2026-10-20 23:29:51 +08:00
truewhile 5615806c2c Merge pull request #49 from truewhile/feat/media-maintenance-and-diagnostics
完善媒体维护、备份恢复与播放诊断
2026-10-20 22:04:10 +08:00
jason jia 1e8fe2396e feat: 完善媒体维护、备份恢复与播放诊断 2026-10-20 14:01:42 +00:00
truewhile e34e81cce0 Merge pull request #48 from truewhile/fix/media-access-cache-search
修复实时事件权限和媒体缓存隔离,移除大库列表截断并缓存排序索引,批量同步 OpenSearch 并处理逐项失败。
2026-10-20 20:40:49 +08:00
jason jia 3cf898756b fix media event access, cache isolation and indexing 2026-10-20 12:39:13 +00:00
truewhile 0b68397265 Merge pull request #46 from truewhile/fix/introdb-api-v4
迁移 TheIntroDB 接口到 v4
2026-10-19 22:26:33 +08:00
truewhile 90bfac3b09 迁移 TheIntroDB 接口到 v4
v1 已删除、v2/v3 已弃用,仅保留只读的 GET /media。本次把 base URL 切到 v4,并适配响应以 tvdb_id 为身份(tmdb_id 字段已移除)。请求仍用 tmdb_id 寻址,v4 仍接受并由服务端解析季集映射。
2026-10-19 22:23:06 +08:00
truewhile f1974a179e 优化 2026-10-19 21:38:55 +08:00
truewhile f2357200be 优化 2026-10-19 21:11:35 +08:00
truewhile ca9a80b862 优化 2026-10-18 22:30:02 +08:00
truewhile 4f19e2210e feat: 字幕轨名称可读化
外挂字幕原来直接拿文件名的语言后缀当名称(chs、chs[BilibiliID_danmu]),
内嵌字幕没有 title 标签时也是裸语言码(chi)。改成统一映射成中文名:
chi/zh -> 中文,chs -> 简体中文,jpn -> 日本語,并把弹幕转存的
[BilibiliID_danmu] 文件标注为「弹幕」,避免和对白字幕混淆。

认不出来的标记保持原样(含 und),Emby 客户端的「字幕 (ASS)」兜底不变。
2026-10-18 17:01:08 +08:00
truewhile 91667c400c bug处理 2026-10-18 15:37:31 +08:00
truewhile 0b8438d798 Merge pull request #45 from truewhile/feature/android-mobile-api
优化
2026-10-18 10:45:05 +08:00
truewhile 2faedae0bf 优化 2026-10-18 10:43:43 +08:00
truewhile fd57d2e59b Merge pull request #43 from truewhile/feature/android-mobile-api
优化
2026-10-18 02:20:26 +08:00
truewhile 8356f5c3c7 优化 2026-10-18 02:19:33 +08:00
truewhile 5d137ab6db Merge pull request #42 from truewhile/feature/android-mobile-api
Feature/android mobile api
2026-10-17 23:25:30 +08:00
truewhile e2133cd76f app兼容接口开发 2026-10-17 23:24:48 +08:00
truewhile 1c45483e1f feat(mobile): add v1 server info and device auth sessions 2026-10-17 12:27:28 +08:00
truewhile 40982f6675 Merge pull request #41 from truewhile/fix/database-playback-stability
修复数据库兼容性与 HLS 播放生命周期问题
2026-10-17 09:31:04 +08:00
jason jia 27dcfc2fe9 fix(playback): reuse completed HLS output and preserve resume positions 2026-10-17 01:30:04 +00:00
jason jia 984b98e61b fix: preserve database compatibility and harden request lifecycles 2026-10-17 01:29:51 +00:00
truewhile 5e496b35f1 Merge pull request #40 from truewhile/fix/search-library-grouping
fix(search): 按媒体库区分同名番剧搜索结果
2026-10-16 23:52:34 +08:00
jason jia 91eb0dadf6 fix(search): keep same series separate across libraries 2026-10-16 15:50:55 +00:00
truewhile 3d7d661199 Merge pull request #39 from truewhile/work
优化媒体搜索页面与返回状态保留
2026-10-16 22:58:45 +08:00
jason jia 8686d8cae6 优化媒体搜索页面与返回状态保留 2026-10-16 14:57:30 +00:00
truewhile 43cfeabea1 bug处理 2026-10-16 14:54:06 +08:00
truewhile d112e8e4db 优化 2026-10-13 23:35:06 +08:00
truewhile 6604691c19 优化 2026-10-13 22:16:14 +08:00
truewhile 23937d8137 优化,bug处理 2026-10-12 23:43:40 +08:00
truewhile 6864a9c7f1 优化 2026-10-12 23:01:46 +08:00
truewhile 079b3ff063 优化兼容性 2026-10-11 23:44:11 +08:00
truewhile 3c30c64a39 优化 2026-10-11 21:44:20 +08:00
truewhile 6186c8b848 优化 2026-10-11 21:29:19 +08:00
truewhile 0db8698fce 优化 2026-10-10 13:49:13 +08:00
truewhile 42703c6760 优化 2026-10-10 12:00:33 +08:00
truewhile 4ed2edab5b 优化,阅读问题处理 2026-10-10 11:26:47 +08:00
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
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
truewhile b0f1ab39b9 优化登录,优化阅读 2026-10-03 21:00:16 +08:00
truewhile 5242d54a06 优化添加段评内容 2026-10-03 18:41:20 +08:00
truewhile e961969207 优化 2026-10-03 18:25:13 +08:00
truewhile b8b02cb3a7 优化 2026-10-03 14:21:07 +08:00
truewhile 60cfad47e5 优化 2026-10-03 14:05:24 +08:00
truewhile 47c8bfdd6c bug处理 2026-10-03 13:38:18 +08:00
truewhile ce3baee8f1 优化 2026-10-02 23:54:37 +08:00
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
truewhile c4e9926352 优化 2026-10-02 18:44:07 +08:00
truewhile f3b88fad7c 优化 2026-10-02 17:56:16 +08:00
truewhile 2c6646beea bug处理 2026-10-02 15:14:36 +08:00
truewhile 83ec494a36 bug处理 2026-10-02 13:14:29 +08:00
truewhile b8648f0049 bug处理 2026-10-02 12:02:29 +08:00
truewhile 5acfa6292a 优化 2026-10-02 11:05:59 +08:00
truewhile 42343212c5 Merge pull request #37 from truewhile/feature/reading
优化
2026-10-01 23:30:58 +08:00
truewhile 6584519bd8 优化 2026-10-01 23:29:49 +08:00
truewhile 565a55b194 Merge pull request #36 from truewhile/feature/reading
添加阅读功能
2026-10-01 22:19:39 +08:00
truewhile 9036ad04e7 镜像问题处理 2026-10-01 22:18:37 +08:00
truewhile ce4319ec25 优化阅读功能 2026-10-01 21:57:25 +08:00
truewhile c1ff55ffbf 开发 2026-09-30 19:22:24 +08:00
truewhile fa41936095 feat(reader): P3+P4——音频源与漫画/图片源全链路支持
后端:
- /api/reader/media 签名媒体代理:HMAC-SHA256(jwtSecret, bookID|url) 防开放
  中转滥用;免 JWT(audio/img 元素带不了头)但需有效签名+书籍存在
- 携书源级请求头与 Referer(防盗链)、Range 透传(音频拖动进度 206)、
  m3u8 播放列表分片/密钥地址改写为签名代理(hls.js 无感续播)
- 音频/图片地址按最终页面 URL 绝对化,imageStyle 透传;
  GetContentForBook 将 tracks/images 统一改写为代理地址
前端:
- ReaderAudioPanel(仿 ReadAloudDialog):hls.js 播 m3u8 / <audio> 直链,
  上一章/播放暂停/下一章、进度条拖动、倍速 0.75–2x、按秒记忆进度、
  播完自动下一章、点背景呼出菜单
- ReaderComic:上下滚动 / 左右单页双模式(复用翻页/滚动切换)、
  图片懒加载+失败占位、滚动模式左右点击滚屏/中间呼菜单、
  单页模式点击区翻页、菜单进度条按图片序号、按图片序号记忆进度
- 菜单进度条按内容类型适配(文本页/音频章节/漫画图片序号)
2026-09-30 17:12:16 +08:00
truewhile 9cc21a47c5 feat(reader): P2 收尾——冒烟 CLI、书架正文链路、替换净化规则
- SmokeChain 结构化全链路冒烟(搜索→详情→目录→正文,分阶段失败定位),
  SmokeSource 支持不落库直测;Debug 接口改为结构化日志输出
- cmd/reader-smoke:批量书源冒烟 CLI(-file/-url 导入、并发、
  各阶段通过率汇总、-json 完整报告)
- GetContentForBook:书架维度正文(章节缓存 + 书源 replaceRegex +
  用户替换净化规则),前端阅读器切换到该接口
- 替换净化规则 CRUD(后端 + /reader/replace 前端页),用户规则
  按 scope/excludeScope 书名过滤、正则带匹配超时防回溯挂死
- rule.ApplyUserReplace 导出(regexp2 MatchTimeout)
2026-09-30 16:55:52 +08:00
truewhile 5976a8d310 feat(reader): P2——goja JS 引擎接入 + Go 原生 java.* 桥接
- rule/goja.go:纯 Go JS 运行时(goja),脚本编译缓存 + 超时中断(默认10s),
  绑定集合对齐 legado evalJS(java/cookie/cache/source/book/result/baseUrl/
  chapter/title/src/page/key)
- rule/bridge.go:java 对象函数名与 legado JsExtensions 一一对应(存量书源
  硬编码这些名字),全部 Go 原生实现:
  网络 ajax/ajaxAll/connect/get/post/head(经 MeBox HTTP 客户端,响应对象
  带 body()/code()/url()/header());规则回调 getString/getStringList/
  getElements/put/get(桥回当前解析器);编解码 base64/hex/md5(16/32)/
  digest/HMac/encodeURI/timeFormat(SimpleDateFormat 转 Go 布局)
- 对称加密:createSymmetricCrypto + aes/des/3des 全家桶,transformation
  三段式解析(AES/DES/3DES × CBC/ECB/CTR/OFB/CFB × PKCS5/7/Zero/NoPadding,
  ECB 手工块循环),密钥长度自动补齐,decryptStr 自动识别 Base64/Hex
- get(url,headers) 与 get(key) 按 arity 分发(对齐 Kotlin 重载)
- 浏览器/文件/压缩包类函数明确抛不支持(服务端沙箱边界)
- URL 规则 JS:analyzeJs(@result 链式)、{{js}} 内嵌、option js/bodyJs
- execute 记录 Set-Cookie 进 cookie jar(java/cookie.getCookie 可读)
- 测试:编码/摘要/AES 向量/CBC+ECB 往返/ajax httptest/URL JS/沙箱边界,
  全部通过;修复 java.get 双注册、测试缺 SetContent 与 md5Encode16 期望值
2026-09-30 16:36:32 +08:00
truewhile d0b4255aa8 feat(reader): P1 前端——首页影视/阅读切换 + 书架/搜索/书源/详情/阅读器
- 首页顶部「影视/阅读」分段切换(zustand 持久化,阅读模式直渲染书架)
- /reader/* 独立全屏路由(不套影视 Layout):
  书架(响应式网格 3→8 列)、搜索页(结果流+失败书源明细)、
  书源管理(导入/启停/删除/快速调试日志)、详情页(目录+加书架)
- 阅读器仿 legado ReadMenu:6 套内置主题(日/夜色值对齐 readConfig.json)、
  CSS 多栏分页 + 平移翻页/滚动双模式、三区点击 + 键盘翻页(桌面)、
  上下章、目录抽屉(Virtuoso 虚拟滚动)、字号/行距/段距调节、
  进度记忆(服务端同步 + 下一章预取)
- 桌面兼容:内容列 900px 限宽居中、鼠标悬停态、键盘快捷键
- 后端:详情/目录/正文接口支持 source_url 定位书源(书架链路)
2026-09-30 16:07:28 +08:00
truewhile b8e6418326 feat(reader): legado 书源规则引擎 Go 移植 + 阅读 P0 后端
- internal/service/reader/rule/:逐方法移植 legado analyzeRule 包
  (AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/
  AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer),JS 规则留 P2 接入点
- 书源导入(JSON数组/对象/Base64/URL)、多源聚合搜索(legado 四档
  排序合并)、详情/目录/正文(nextContentUrl 翻页合并)
- 数据模型 4 表注册迁移;/api/reader/* 路由组(书源/搜索/书架/进度/调试)
- 单测 + httptest 全链路端到端测试
- docs/reader-ui-spec.md:legado UI 交互仿制规格(供 P1 前端使用)
2026-09-30 15:41:58 +08:00
truewhile c84f2604ed docs(reader): 阅读(legado 书源兼容)功能实施计划 2026-09-30 14:39:51 +08:00
truewhile 1e6909ddbf 优化播放 2026-09-30 11:20:27 +08:00
truewhile 8136e6ecb1 fix(emby): 电影条目不下发空的 SeasonId/SeriesId,修复小幻影视起播失败
小幻影视(RodelPlayer)2.2610 会把电影条目的 SeasonId 空串原样传进自己的
播放准备逻辑并抛 ArgumentException(Parameter 'seasonId'),客户端日志里
表现为 failedStage=PreparationFailed、playback generation null,也就是
点播放立刻「播放失败」;剧集条目的 SeasonId 非空,所以同一台设备放剧集正常。

Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键)而不是下发空串。
这里对非剧集条目省略 SeasonId/SeriesId/SeasonName/SeriesName(顺带去掉电影被
标成「特别篇」的 SeasonName),剧集则保留真实季/剧信息、只清空值。

同时回退上一版针对 115 直链 UA 绑定的同源一跳改动:客户端日志显示该客户端的
播放器层与 API 层 UA 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
2026-09-28 22:51:40 +08:00
truewhile 1d73374fff fix(emby): 网盘直链绑定 UA,第三方播放器保留同源一跳
115 直链与换取时的 User-Agent 逐字符绑定,换错一律 403。服务端换链用的是邮件客户端 API 层的 UA,而真正拉流的是播放器组件,RodelPlayer 2.2610 两层 UA 不一致(API 层带系统后缀)后会拿到 403 直链,表现为播放失败。非浏览器客户端改为 302 到同源 strm 端点,由播放器自己换链;浏览器仍走服务端换链一跳。
2026-09-28 22:29:36 +08:00
truewhile bad07f7add 优化支持0.5集 2026-09-27 13:52:17 +08:00
truewhile cb539afd2a 优化 2026-09-27 13:04:28 +08:00
truewhile 594166f696 bug处理 2026-09-25 20:30:49 +08:00
truewhile e0b07492a5 优化网页播放跳转 2026-09-25 13:24:10 +08:00
truewhile 3be6978e70 优化 2026-09-25 13:10:23 +08:00
truewhile 065652d758 优化 2026-09-24 22:27:09 +08:00
truewhile e273b5e87d 优化 2026-09-24 11:43:17 +08:00
truewhile c9c612df1b bug处理 2026-09-23 23:07:01 +08:00
truewhile e2cd32af53 处理yamby继续播放bug 2026-09-23 22:31:09 +08:00
truewhile 28485ed429 优化 2026-09-23 17:01:08 +08:00
truewhile 34ccf14cda 优化视频头尾跳过功能 2026-09-23 16:07:28 +08:00
truewhile 44fde6c5a0 优化 2026-09-23 13:53:50 +08:00
truewhile 94ef0116b1 添加跳过片头片尾功能 2026-09-23 10:22:13 +08:00
truewhile 016c6687e9 优化起播 2026-09-22 22:30:52 +08:00
truewhile b5ce4646cf 添加新功能,完善项目 2026-09-22 16:22:29 +08:00
truewhile 47899284a5 优化缩略图 2026-09-20 15:16:40 +08:00
truewhile c24ebe181d 优化缩略图 2026-09-20 14:13:49 +08:00
truewhile 9b2b592003 优化 2026-09-20 13:33:45 +08:00
truewhile 0c4668c156 优化 2026-09-19 21:30:32 +08:00
truewhile 474513f18a bug处理 2026-09-18 23:35:56 +08:00
truewhile 0810bfb234 bug处理 2026-09-18 22:52:09 +08:00
663 changed files with 92694 additions and 3298 deletions
-2
View File
@@ -1,2 +0,0 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+1 -1
View File
@@ -187,7 +187,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
+33 -26
View File
@@ -4,10 +4,12 @@
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
#
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
# 不打 release tag,只构建带 -beta 标识的产物供测试。
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
# 不打 release tag、不发布 GitHub Release、不部署服务器。
name: Beta Build
@@ -22,9 +24,6 @@ permissions:
contents: read
packages: write
env:
BETA_VERSION_PREFIX: beta
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
@@ -32,17 +31,24 @@ jobs:
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
binary_version: ${{ steps.version.outputs.binary_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
git fetch origin version || true
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
@@ -59,7 +65,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: go vet
@@ -71,13 +77,13 @@ jobs:
- name: go build (host)
run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist)
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
- name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -87,7 +93,8 @@ jobs:
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
@@ -98,13 +105,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -115,6 +115,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=beta
type=raw,value=${{ needs.test-and-build.outputs.version }}
- name: Build & push
uses: docker/build-push-action@v6
with:
@@ -123,11 +132,9 @@ jobs:
push: true
provenance: false
sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
VERSION=${{ needs.test-and-build.outputs.binary_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+35 -2
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
@@ -40,6 +40,39 @@ jobs:
- name: go test
run: go test ./...
database-compatibility:
name: SQLite / PostgreSQL ${{ matrix.postgres }}
runs-on: ubuntu-latest
strategy:
matrix:
postgres: [16, 17]
services:
postgres:
image: postgres:${{ matrix.postgres }}-alpine
env:
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox-test-only
POSTGRES_DB: mebox_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U mebox -d mebox_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MEBOX_TEST_POSTGRES_DSN: postgres://mebox:mebox-test-only@127.0.0.1:5432/mebox_test?sslmode=disable
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- name: Database regression tests
run: go test ./internal/database ./internal/repository -count=1
frontend:
name: Frontend (Node)
runs-on: ubuntu-latest
@@ -78,7 +111,7 @@ jobs:
ci-success:
name: CI Success
runs-on: ubuntu-latest
needs: [backend, frontend, smoke]
needs: [backend, frontend, database-compatibility, smoke]
if: success()
steps:
- run: echo "CI passed, ready for release"
+2 -1
View File
@@ -80,4 +80,5 @@ media/
tools/
verify-cache/
verify-data/
.zcode/
.zcode/
.tmp-src
+114
View File
@@ -0,0 +1,114 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
dist-release/
# Data / runtime
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-smoke-data/
.tmp-deploy-smoke-cache/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mebox.pid
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
# WorkBuddy workspace (local AI assistant memory)
.workbuddy/
# Editor backups
*~
.tmp_*
# Runtime / local-only artifacts (清理补充)
.tmp/
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+9
View File
@@ -84,6 +84,15 @@ go test ./internal/service -run "TestOrganize|TestSubscription|TestMTeam" -count
go test ./cmd/server -count=1
```
数据库兼容测试默认覆盖 SQLite。连接专用测试 PostgreSQL 后可运行真实的双数据库回归:
```bash
MEBOX_TEST_POSTGRES_DSN='postgres://mebox:test-password@127.0.0.1:5432/mebox_test?sslmode=disable' \
go test ./internal/database ./internal/repository -count=1
```
测试账号需要创建、删除 schema 的权限;每个用例使用独立的临时 schema。CI 会分别在 PostgreSQL 16、17 上执行这些检查。
### 代码要求
- Go 代码使用 `gofmt`。
+8 -5
View File
@@ -3,7 +3,7 @@
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
@@ -25,7 +25,7 @@ COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
# .http_scheme 选择协议并在失败时回退,无需用户改配置。
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/mebox-healthcheck"]
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
# rewriting /etc/passwd or /etc/group on every container start.
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck
RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck
CMD ["/entrypoint.sh"]
+1 -1
View File
@@ -22,7 +22,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+1 -1
View File
@@ -21,7 +21,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+206
View File
@@ -0,0 +1,206 @@
// reader-smoke 是阅读书源兼容性冒烟工具:
// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
//
// 用法:
//
// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/reader"
)
func main() {
file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
key := flag.String("key", "斗破苍穹", "搜索关键词")
concurrency := flag.Int("c", 4, "并发数")
timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
flag.Parse()
payload := ""
switch {
case *file != "":
data, err := os.ReadFile(*file)
if err != nil {
fatal("读取文件失败: %v", err)
}
payload = string(data)
case *urlFlag != "":
client := helper.NewSiteHTTPClient(30, true)
req, err := http.NewRequest("GET", *urlFlag, nil)
if err != nil {
fatal("构造请求失败: %v", err)
}
for k, v := range helper.HTTPHeaderPresets() {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
fatal("拉取书源失败: %v", err)
}
defer resp.Body.Close()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
payload = sb.String()
default:
fatal("需要 -file 或 -url 指定书源来源")
}
sources := reader.ParseSourcePayload(payload)
if len(sources) == 0 {
fatal("未从输入中识别到书源")
}
// 书源文件缓存根目录: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))
sem := make(chan struct{}, max(1, *concurrency))
var wg sync.WaitGroup
for i, raw := range sources {
wg.Add(1)
sem <- struct{}{}
go func(i int, raw string) {
defer wg.Done()
defer func() { <-sem }()
ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
defer cancel()
res := svc.SmokeSource(ctxSrc, raw, *key)
results[i] = res
status := "✓"
if !res.OK {
status = "✗"
}
fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
}(i, raw)
}
wg.Wait()
// 汇总
var searchOK, infoOK, tocOK, contentOK, allOK int
failedAt := map[string]int{}
for _, r := range results {
if r == nil {
continue
}
switch r.FailedAt {
case "":
allOK++
searchOK++
infoOK++
tocOK++
contentOK++
case "search":
failedAt["search"]++
case "info":
searchOK++
failedAt["info"]++
case "toc":
searchOK++
infoOK++
failedAt["toc"]++
case "content":
searchOK++
infoOK++
tocOK++
failedAt["content"]++
}
}
n := len(results)
pct := func(v int) string {
if n == 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
}
fmt.Printf("\n==== 冒烟报告 ====\n")
fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
for _, stage := range []string{"search", "info", "toc", "content"} {
if failedAt[stage] > 0 {
fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
}
}
if *jsonOut {
out, err := json.MarshalIndent(results, "", " ")
if err != nil {
fatal("序列化报告失败: %v", err)
}
fmt.Println(string(out))
}
}
func stageCN(r *reader.SmokeChainResult) string {
switch r.FailedAt {
case "":
return "完成"
case "search":
return "搜索"
case "info":
return "详情"
case "toc":
return "目录"
case "content":
return "正文"
case "parse":
return "解析"
default:
return r.FailedAt
}
}
func truncate(s string, n int) string {
rs := []rune(strings.TrimSpace(s))
if len(rs) <= n {
if len(rs) == 0 {
return "(未命名)"
}
return string(rs)
}
return string(rs[:n]) + "…"
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
os.Exit(1)
}
+12 -1
View File
@@ -79,6 +79,9 @@ func (a *application) start() error {
}
}
if err := database.ApplyPendingSQLiteRestore(context.Background(), a.cfg, a.logger); err != nil {
return fmt.Errorf("apply pending database restore: %w", err)
}
db, err := database.Open(a.cfg, a.logger)
if err != nil {
a.logger.Error("database open failed", zap.Error(err))
@@ -88,7 +91,7 @@ func (a *application) start() error {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
@@ -110,6 +113,14 @@ func (a *application) start() error {
a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
// 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms),
// 否则升级后播放器仍会一开播就弹跳过按钮。
if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil {
a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err))
} else if cleaned > 0 {
a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned))
}
if err := a.services.Auth.SeedAdmin(context.Background()); err != nil {
a.logger.Warn("seed admin failed", zap.Error(err))
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bufio"
"net"
"sync"
"time"
)
// 开启 HTTPS 后,同一个端口会同时面对两类连接:
//
// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务;
// - 明确访问 http:// 的客户端——以前只会撞出一句
// "Client sent an HTTP request to an HTTPS server.",用户看不懂。
//
// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节:
// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。
// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被
// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。
const tlsRecordHandshake = 0x16
// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住
// goroutine。分流器按实例持有该值,便于测试用更短的超时。
const defaultPeekTimeout = 10 * time.Second
// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。
type splitListener struct {
ln net.Listener
tlsCh chan net.Conn
plainCh chan net.Conn
quit chan struct{}
once sync.Once
peek time.Duration
}
func newSplitListener(ln net.Listener) *splitListener {
return newSplitListenerWithTimeout(ln, defaultPeekTimeout)
}
func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener {
s := &splitListener{
ln: ln,
tlsCh: make(chan net.Conn),
plainCh: make(chan net.Conn),
quit: make(chan struct{}),
peek: peek,
}
go s.acceptLoop()
return s
}
// tlsListener 返回只收到 TLS 连接的 listener。
func (s *splitListener) tlsListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit}
}
// plainListener 返回只收到明文 HTTP 连接的 listener。
func (s *splitListener) plainListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit}
}
func (s *splitListener) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
// 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed,
// 由持有它们的 http.Server 自行收尾。
s.stop()
return
}
go s.dispatch(conn)
}
}
func (s *splitListener) dispatch(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.peek))
first, err := br.Peek(1)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return
}
peeked := &peekedConn{Conn: conn, r: br}
ch := s.plainCh
if first[0] == tlsRecordHandshake {
ch = s.tlsCh
}
select {
case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭
case <-s.quit:
_ = conn.Close()
}
}
func (s *splitListener) stop() {
s.once.Do(func() { close(s.quit) })
}
// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。
type channelListener struct {
addr net.Addr
conns <-chan net.Conn
quit <-chan struct{}
}
func (l *channelListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.quit:
return nil, net.ErrClosed
}
}
// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭,
// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。
func (l *channelListener) Close() error { return nil }
func (l *channelListener) Addr() net.Addr { return l.addr }
// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"io"
"net"
"net/http"
"testing"
"time"
)
func TestSplitListenerRoutesByFirstByte(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
split := newSplitListener(raw)
addr := raw.Addr().String()
// TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。
handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c}
tlsClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tlsClient.Close() }()
if _, err := tlsClient.Write(handshake); err != nil {
t.Fatal(err)
}
tlsConn := <-split.tlsCh
defer func() { _ = tlsConn.Close() }()
got := make([]byte, len(handshake))
if _, err := io.ReadFull(tlsConn, got); err != nil {
t.Fatalf("read peeked tls bytes: %v", err)
}
if string(got) != string(handshake) {
t.Fatalf("tls bytes = %v, want %v", got, handshake)
}
// 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。
request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n"
plainClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainClient.Close() }()
if _, err := plainClient.Write([]byte(request)); err != nil {
t.Fatal(err)
}
plainConn := <-split.plainCh
defer func() { _ = plainConn.Close() }()
head := make([]byte, len("GET /"))
if _, err := io.ReadFull(plainConn, head); err != nil {
t.Fatalf("read peeked plain bytes: %v", err)
}
if string(head) != request[:len(head)] {
t.Fatalf("plain bytes = %q, want prefix of %q", head, request)
}
// 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。
_ = raw.Close()
for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} {
deadline := time.Now().Add(3 * time.Second)
for {
_, err := ln.Accept()
if err == net.ErrClosed {
break
}
if time.Now().After(deadline) {
t.Fatalf("Accept after close = %v, want net.ErrClosed", err)
}
time.Sleep(10 * time.Millisecond)
}
}
}
// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。
func TestSplitListenerDropsIdleConnection(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = raw.Close() }()
split := newSplitListenerWithTimeout(raw, 200*time.Millisecond)
conn, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
select {
case c := <-split.plainCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case c := <-split.tlsCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case <-time.After(time.Second):
}
}
func TestHTTPSRedirectTarget(t *testing.T) {
cases := []struct {
name string
host string
rawURL string
want string
wantErr bool
}{
{name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"},
{name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"},
{name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"},
{name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"},
{name: "rejects empty host", host: "", rawURL: "/", wantErr: true},
{name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true},
{name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil)
if err != nil {
t.Fatal(err)
}
req.Host = tc.host
if host := redirectHostHeader(req); host == "" {
if !tc.wantErr {
t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host)
}
return
}
if tc.wantErr {
t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host)
}
if got := httpsRedirectURL(tc.host, req); got != tc.want {
t.Fatalf("redirect target = %q, want %q", got, tc.want)
}
})
}
}
+126 -18
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -17,6 +19,14 @@ import (
"github.com/truewhile/MeBox/internal/service"
)
// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。
//
// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启
// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出
// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。
// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。
const httpSchemeFileName = ".http_scheme"
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct {
cert tls.Certificate
@@ -35,6 +45,9 @@ type tlsPair struct {
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
//
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
//
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
type serverManager struct {
cfg *config.Config
log *zap.Logger
@@ -43,6 +56,7 @@ type serverManager struct {
mu sync.Mutex
srv *http.Server
redirectSrv *http.Server
ln net.Listener
pair *tlsPair
stopCh chan struct{}
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
return nil
}
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair
if oldLn != nil {
_ = oldLn.Close() // 释放端口后再绑定新监听
}
m.srv, m.ln, m.pair = nil, nil, nil
m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil
firstErr := m.bind(pair)
if firstErr != nil {
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
}
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
m.drain(oldSrv)
m.drain(oldRedirect)
m.logServerReady()
m.maybeStartAutoReloadLocked()
return firstErr
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
close(m.stopCh)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.srv == nil {
return nil
targets := []*http.Server{m.srv, m.redirectSrv}
m.mu.Unlock()
var firstErr error
for _, srv := range targets {
if srv == nil {
continue
}
if err := srv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return m.srv.Shutdown(ctx)
return firstErr
}
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
return pair.version == m.pair.version
}
// bind 创建并按需启用 TLS 的监听,异步开始服务。
// bind 创建监听,异步开始服务。
//
// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接
// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动
// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS
// server."。
func (m *serverManager) bind(pair *tlsPair) error {
ln, err := net.Listen("tcp", m.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", m.addr, err)
}
srv := &http.Server{
Handler: m.handler,
if pair == nil {
srv := m.newHTTPServer(m.handler)
m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil
m.writeSchemeState(nil)
go m.serve(srv, ln)
return nil
}
split := newSplitListener(ln)
srv := m.newHTTPServer(m.handler)
tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
redirect := m.newHTTPServer(m.httpsRedirectHandler())
m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair
m.writeSchemeState(pair)
go m.serve(srv, tlsLn)
go m.serve(redirect, split.plainListener())
return nil
}
func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Handler: handler,
ReadHeaderTimeout: 15 * time.Second,
}
if pair != nil {
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
}
// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
// 同路径的 https://。
//
// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期
// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。
func (m *serverManager) httpsRedirectHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := redirectHostHeader(r)
if host == "" {
// 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "HTTPS is enabled; open https://<host>:%d instead.\n", m.cfg.App.Port)
return
}
status := http.StatusFound
if r.Method != http.MethodGet && r.Method != http.MethodHead {
status = http.StatusTemporaryRedirect // 保住方法与请求体
}
w.Header().Set("Location", httpsRedirectURL(host, r))
w.WriteHeader(status)
})
}
// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。
func httpsRedirectURL(host string, r *http.Request) string {
target := "https://" + host + r.URL.EscapedPath()
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
return target
}
// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射
// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符
// 带进 Location 造成任意跳转。
func redirectHostHeader(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") {
return ""
}
return host
}
// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。
// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。
func (m *serverManager) writeSchemeState(pair *tlsPair) {
dataDir := strings.TrimSpace(m.cfg.App.DataDir)
if dataDir == "" {
return
}
scheme := "http"
if pair != nil {
scheme = "https"
}
path := filepath.Join(dataDir, httpSchemeFileName)
if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename.
m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err))
}
m.srv, m.ln, m.pair = srv, ln, pair
go m.serve(srv, ln)
return nil
}
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
@@ -250,6 +355,9 @@ func (m *serverManager) drain(s *http.Server) {
if s == nil {
return
}
// Stop reusing old connections before Reload returns. Asynchronous Shutdown
// alone leaves a window where a client receives the previous HTTP/TLS mode.
s.SetKeepAlivesEnabled(false)
go func(s *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
@@ -271,7 +379,7 @@ func (m *serverManager) logServerReady() {
zap.String("listen", m.addr),
)
if m.pair != nil {
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS",
zap.String("addr", m.addr),
)
}
+139
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
t.Fatal("version should change after files replaced")
}
}
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
func TestWriteSchemeState(t *testing.T) {
m := newTestServerManager(t)
m.cfg.App.DataDir = t.TempDir()
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
m.writeSchemeState(nil)
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
}
m.writeSchemeState(&tlsPair{})
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
}
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
m.cfg.App.DataDir = ""
_ = os.Remove(path)
m.writeSchemeState(nil)
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
}
}
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
certPEM, keyPEM := makeTestPairPEM(t)
cfg := &config.Config{}
cfg.App.Port = 8080
cfg.App.DataDir = t.TempDir()
cfg.App.HTTPSEnabled = true
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
})
m := newServerManager(cfg, zap.NewNop(), handler)
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
if err := m.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
addr := m.ln.Addr().String()
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
m.addr = addr
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("scheme state = %q err=%v, want https", data, err)
}
plain := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := plain.Get("http://" + addr + "/library?tab=1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = postResp.Body.Close() }()
if postResp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
}
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
t.Fatalf("POST Location = %q, want %q", got, want)
}
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
t.Fatal("append test certificate to pool failed")
}
secure := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
secureResp, err := secure.Get("https://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = secureResp.Body.Close() }()
body, err := io.ReadAll(secureResp.Body)
if err != nil {
t.Fatal(err)
}
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
}
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
cfg.App.HTTPSEnabled = false
if err := m.Reload(); err != nil {
t.Fatal(err)
}
if m.redirectSrv != nil {
t.Fatal("redirect server should be released when HTTPS is off")
}
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("scheme state = %q err=%v, want http", data, err)
}
plainResp, err := plain.Get("http://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainResp.Body.Close() }()
plainBody, err := io.ReadAll(plainResp.Body)
if err != nil {
t.Fatal(err)
}
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf8f5" />
<meta name="description" content="MeBox — 个人家庭媒体中心" />
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
<title>MeBox</title>
<script type="module" crossorigin src="/assets/index-CcRvbC_F.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-B0Z9INg1.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-data-CD8zvwOM.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-Cz-iIQjW.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-motion-FEuwvkVZ.js">
<link rel="modulepreload" crossorigin href="/assets/auth-D3Hf5Dnr.js">
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-nIJoh1kU.js">
<link rel="stylesheet" crossorigin href="/assets/index-BPz3c5Oc.css">
</head>
<body class="font-body antialiased">
<div id="root"></div>
</body>
BIN
View File
Binary file not shown.
+4 -2
View File
@@ -62,7 +62,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -105,7 +105,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -115,7 +115,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -73,7 +73,9 @@ services:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+4 -2
View File
@@ -44,7 +44,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -87,7 +87,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -131,7 +131,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# MeBox 容器健康检查。
#
# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再
# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里
# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远
# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生
# "first record does not look like a TLS handshake"。
#
# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见
# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议,
# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。
set -u
port="${MEBOX_APP_PORT:-8080}"
data_dir="${MEBOX_APP_DATA_DIR:-/data}"
scheme_file="$data_dir/.http_scheme"
scheme=""
if [ -r "$scheme_file" ]; then
scheme="$(tr -d '[:space:]' <"$scheme_file")"
fi
if [ "$scheme" = "https" ]; then
first="https"
second="http"
else
first="http"
second="https"
fi
for proto in "$first" "$second"; do
# --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。
if busybox wget -q --spider -T 3 --no-check-certificate \
"$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then
exit 0
fi
done
exit 1
+636
View File
@@ -0,0 +1,636 @@
# 115 云下载 → STRM 同步 → 自动刮削 · 实施方案
| 项 | 内容 |
| :--- | :--- |
| 状态 | 设计已定稿,待实现(P0 未开工) |
| 适用版本 | MeBox 当前主线(`internal/service/strm_*`、`internal/service/cloud115/*`) |
| 关联文档 | `115doc/115开放平台/API列表/云下载/*`(仓库内离线文档) |
| 最后核对 | 本文所有代码事实均按仓库当前代码逐条核对,行号仅供定位参考 |
---
## 1. 目标与范围
### 1.1 目标
在「115 媒体库映射」(即 STRM 管理中的一条 `StrmSyncPath`:115 目录 → 本地目录 .strm)之上提供云下载入口,用户提交磁力 / BT 种子 / ed2k / HTTP 链接后:
1. 任务提交到 115 离线(云下载);
2. 后台轮询 115 任务状态;
3. 任务完成后**自动触发**该同步目录的 STRM 增量同步;
4. 同步完成后**自动扫描**对应媒体库,生成 Media 行;
5. **自动刮削**新增媒体,无需用户再点一次。
全程无需离开 MeBox,链路上的每个阶段在任务列表里可见。
### 1.2 明确不做(本期范围外)
| 项 | 决定 | 原因 |
| :--- | :--- | :--- |
| 磁力链接选择部分文件下载 | **不做** | 开放平台 `/open/offline/add_task_urls` 无文件选择参数,且 `/open/offline/torrent` 只接受已上传种子的 `torrent_sha1` + `pick_code`,无法解析 magnet。见第 6 节 |
| `.torrent` 勾选文件 UI | **不做** | 用户已确认本期不做选文件;种子按全选提交 |
| 磁力转种子(DHT 抓 metadata) | **不做** | 需引入 torrent 依赖与常驻 DHT 网络行为,本期不引入新依赖 |
| 非 115 网盘的离线下载 | **不做** | CD2 / OpenList / 本地目录无对应能力 |
| 普通用户自助使用 | **不做** | 首期仅管理员(沿用 `AdminRequired` + `can_manage_strm` 语义),权限位见 9.3 |
| 下载完成后自动整理/重命名(Organize) | **不做** | 与 STRM 映射模型冲突,`.strm` 必须与远端路径一一对应 |
### 1.3 非目标(但需保证不被破坏)
- 现有 STRM 手动同步 / Cron 同步 / 下载队列 / 上传队列行为不变;
- 115 令牌自动刷新机制不得被第二个客户端实例破坏(见 3.3 关键约束 K1);
- 刮削不重复刷已 `matched` 的媒体(沿用 `EnqueueLibrary` 的候选过滤)。
---
## 2. 现状核实
### 2.1 已具备
| 能力 | 位置 | 说明 |
| :--- | :--- | :--- |
| 115 OpenAPI 客户端 | `internal/service/cloud115/client.go`、`const.go:16` | `OpenClient`,`ProAPIBase = https://proapi.115.com`,`doJSON` 内建重试与令牌刷新 |
| 115 授权(PKCE/扫码)与令牌落库 | `cloud115/oauth.go`、`service/strm_115_oauth.go` | `OnTokenRefreshed` → `persist115Tokens` 写回 `StrmAccount.Config` |
| 115 文件上传 / 秒传 | `cloud115/upload.go:217` | `Upload(ctx, filePath, parentCID, signKey, signVal)` → `UploadCompleteResult{FileId, PickCode, Sha1}` |
| 115 目录创建 / 查找 | `cloud115/upload.go:318`(`MkDir`)、`cloud115/open.go:126`(`FindNamedContentInParent`) | 可用于建「云下载/种子文件」暂存目录 |
| 115 全局限流/熔断 | `cloud115/throttle_manager.go` | `GetGlobalThrottleManager()` |
| STRM 同步(增量/全量) | `service/strm_sync.go:82` `StartSync` | 已运行时返回「该目录正在同步中」,不排队 |
| STRM 目录/记录/队列数据层 | `model/strm.go`、`repository/strm_repository.go` | — |
| 扫描 + 自动刮削入口 | `service/scanner_scan.go`、`scanner_post_scan.go:19` | `startAutoScrape` 受 `scrape.auto_on_scan` 全局开关约束 |
| 刮削入队 | `service/scraper_queue.go:219` `EnqueueLibrary` | 仅挑 `scrape_status` 为空的 `pending` 行,天然幂等、不会重刷已匹配 |
| 周期任务框架 | `service/scheduler.go:118` `scheduledJob` | 新增 job 只需在 `Start` 里 append |
| 服务装配 | `service/service_builder.go:148` | `NewStrmService` 之后才有 `Cloud115`,可作为依赖注入范式 |
### 2.2 缺口(本方案要补的)
| 缺口 | 影响 |
| :--- | :--- |
| 全仓无任何离线/云下载代码(`grep -r "offline" internal/service/cloud115/` 无命中) | 需从零封装 4 个 API |
| `finishSync` 结束后**没有任何回调**(`strm_sync.go:323`) | 「同步完成 → 扫描」链路是断的,必须新增 hook |
| 无「本地路径 → 媒体库」反查 | 需新增 repository 查询 |
| 无 `.torrent` multipart 上传端点(现有仅 reader 书源、DB 恢复用到 `FormFile`) | 需新增端点 + 上传大小限制 |
| 无云下载任务表 | 需新增模型 + 仓储 + 迁移注册 |
---
## 3. 总体设计
### 3.1 链路时序
```
[前端] 提交(多行链接 / .torrent 文件)+ 选择目标 STRM 同步目录
│
▼
[CloudDownloadService.Submit]
│ 1) 校验账号可用、同步目录启用
│ 2) 配额预检 GET /open/offline/get_quota_info
│ 3) .torrent: 上传到「云下载/种子文件」→ POST /open/offline/torrent 拿 info_hash
│ 4) POST /open/offline/add_task_urls(批量 ≤115/批)或 /open/offline/add_task_bt
▼
[DB] CloudDownloadTask: status=submitted(逐条记录每个链接的受理结果)
│
▼
[Scheduler: cloud_download_poll 每 60s]
│ GET /open/offline/get_task_list?page=N 逐页
│ 按 info_hash(优先)/ url(回退)匹配本地任务
│ status 0/1 → downloading(percentDone) | 2 → succeeded | -1 → failed
▼
[Pipeline: succeeded 任务]
│ A. 收集受影响 syncPathID(30s 去抖合并)
│ B. StrmService.StartSync(syncPathID, incremental)
▼
[StrmService.finishSync] → SetSyncFinishHook(ok)
│ C. 反查 LocalPath 对应的媒体库 → Scanner.ScanLibrary(libraryID)
│ D. Scraper.EnqueueLibrary(libraryID, 默认 options)(仅 pending 行)
▼
[DB] 回写 Stage=done;前端列表展示各阶段结果
```
### 3.2 模块职责
| 模块 | 职责 | 明确不负责 |
| :--- | :--- | :--- |
| `cloud115/offline.go` | 纯 API 封装:参数拼装、响应解析、错误码映射 | 不碰数据库、不做编排 |
| `CloudDownloadService` | 输入解析、校验、提交、状态机、轮询、编排 | 不直接读写 .strm 文件 |
| `StrmService` | 现有同步逻辑 + 新增完成回调 | 不感知云下载概念 |
| `ScannerService` / `ScraperService` | 现有扫描/刮削逻辑 | 不感知云下载概念 |
### 3.3 关键约束(实现时必须遵守)
**K1 — 必须复用 StrmService 已缓存的 115 客户端,禁止自行 `NewOpenClient`。**
`strm_service.go:524` 的 `newProvider` 里给 `OpenClient` 挂了 `OnTokenRefreshed` → `persist115Tokens`。115 的 `refresh_token` 是一次性轮转的:若云下载服务另建一个客户端,两边各自刷新会互相作废,最终把账号刷成「授权已失效」。
落地方式:给 `StrmService` 增加导出方法
```go
// Open115Client 返回账号已缓存的 115 客户端(含令牌自动刷新与落库)。
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error)
```
内部复用 `providerFor` + `cloud.OpenAPI115Provider` 断言(与 `strm_sync.go:379` 的用法一致)。
**K2 — 去抖。** `walk115Flat`(`strm_sync.go:682`)是整树自适应拉取,不是只看新目录。多个任务在短时间内完成时,同一 syncPath 只应同步一次。
**K3 — 阶段失败隔离。** STRM 同步失败不得把 115 下载状态改成失败;反之亦然。下载状态与后处理阶段分别落在不同字段(见 4.1)。
**K4 — 同步不排队。** `StartSync` 在运行中直接报错,Pipeline 必须自己实现「等待 + 重投」,不能靠重试打满日志。
**K5 — 所有 115 调用走全局 throttle。** 轮询在无活跃任务时不得发起请求。
---
## 4. 数据模型
### 4.1 `model.CloudDownloadTask`(新表 `cloud_download_tasks`)
```go
// internal/model/cloud_download.go
type CloudDownloadTask struct {
Base
AccountID string `gorm:"size:36;index" json:"account_id"`
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由 LocalPath 反查得到,可为空
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
SourceType string `gorm:"size:16;index" json:"source_type"` // magnet / torrent / ed2k / http / ftp
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接(.torrent 时存文件名)
SourceKey string `gorm:"size:512;index" json:"source_key"` // 归一化后的匹配键:大写 info_hash 或 trim 后的 url
InfoHash string `gorm:"size:64;index" json:"info_hash"`
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 路径用
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 路径用
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(本期留空)
Status string `gorm:"size:16;index" json:"status"` // queued/submitted/downloading/succeeded/failed/canceled
Percent int `json:"percent"`
SizeBytes int64 `json:"size_bytes"`
Error string `gorm:"size:1024" json:"error"`
Stage string `gorm:"size:16;index" json:"stage"` // pending/waiting_sync/syncing/scanning/scraping/done/na
StageError string `gorm:"size:1024" json:"stage_error"`
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
ScanAdded int `json:"scan_added"`
ScrapeQueued int `json:"scrape_queued"`
RetryCount int `json:"retry_count"`
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
```
注册点:`model.AllModels()`(`internal/model/model.go:37`)、`repository.Container`(`internal/repository/repository.go:11`)、`service.Container`(`internal/service/service.go:19`)。
> **不复用 `StrmDownloadTask`**:那是「远端元数据 → 本地」的文件搬运队列(`model/strm.go`),语义完全不同,混用会让 `/admin/strm/downloads` 列表出现云下载任务。
### 4.2 状态机
**Status(115 下载侧)**
| 值 | 进入条件 | 终态 |
| :--- | :--- | :--- |
| `queued` | 本地已建行,尚未提交 115 | 否 |
| `submitted` | 115 已受理(`data[].state=true`) | 否 |
| `downloading` | `get_task_list` 返回 status 0 或 1 | 否 |
| `succeeded` | status 2 | **是**,进入 Pipeline |
| `failed` | status -1 / 提交被拒 / 超时(默认 72h) | **是** |
| `canceled` | 用户取消 | **是** |
**Stage(后处理侧)**
| 值 | 含义 |
| :--- | :--- |
| `pending` | 尚未开始(未成功) |
| `waiting_sync` | 已成功,等待去抖窗口结束或等待同一目录的同步结束 |
| `syncing` | STRM 增量同步执行中 |
| `scanning` | 媒体库扫描中 |
| `scraping` | 刮削已入队 |
| `done` | 全链路完成 |
| `na` | 同步目录已删除/禁用,跳过后续(不算失败) |
| `failed` | 阶段失败,`StageError` 记录原因 |
> Stage 失败**不回滚** Status;用户可对 Stage 失败的任务点「重做后处理」。
---
## 5. 115 开放平台对接
### 5.1 接口清单
| 用途 | 方法 | 路径 | 关键参数 | 关键返回 |
| :--- | :--- | :--- | :--- | :--- |
| 添加链接任务 | POST | `/open/offline/add_task_urls` | `urls`(换行分隔,≤115 条)、`wp_path_id` | `data[]{state,code,message,info_hash,url}` |
| 解析 BT 种子 | POST | `/open/offline/torrent` | `torrent_sha1`、`pick_code` | `data{file_size,torrent_name,file_count,info_hash,torrent_filelist[]{size,path,wanted}}` |
| 添加 BT 任务 | POST | `/open/offline/add_task_bt` | `info_hash`、`wanted`、`save_path`、`torrent_sha1`、`pick_code`、`wp_path_id` | `data[]`(成功为空数组) |
| 任务列表 | GET | `/open/offline/get_task_list` | `page` | `data{page,page_count,count,tasks[]{...}}` |
| 配额信息 | GET | `/open/offline/get_quota_info` | 无 | `data{package[]{surplus,used,count,name,expire_info[]},count,surplus,used}` |
| 删除任务(P2) | POST | `/open/offline/del_task` | `info_hash`、`del_source_file` | `data[]` |
| 清空任务(P2) | POST | `/open/offline/clear_task` | `flag`(0~5) | `data[]` |
请求形态:`add_task_*` / `del_task` / `clear_task` / `torrent` 均为 **multipart/form-data**;`get_*` 为 GET query。全部走 `Authorization: Bearer <access_token>`,即现有 `doAuthJSON` 通道。
### 5.2 任务列表字段(轮询用)
| 字段 | 类型 | 用途 |
| :--- | :--- | :--- |
| `info_hash` | string | 首选匹配键 |
| `url` | string | 回退匹配键(链接任务) |
| `status` | int | -1 失败 / 0 分配中 / 1 下载中 / 2 成功 |
| `percentDone` | int | 进度(**注意驼峰**,见 5.4) |
| `size` | int | 任务总字节 |
| `name` | string | 任务名,用于 UI |
| `file_id` / `delete_file_id` | string | 完成后源文件/文件夹 ID(P2 裁剪用) |
| `wp_path_id` | string | 落盘父目录,可用于校验目录一致性 |
| `add_time` / `last_update` | int | 超时判断 |
### 5.3 错误码映射(要落到用户可读文案)
| 码 | 115 文案 | MeBox 提示 |
| :--- | :--- | :--- |
| `1000012` | 云下载配额已用完 | 「115 云下载配额已用尽,请在 115 端购买/领取配额后重试」 |
| `91006` | 存储空间不足 | 「115 存储空间不足」 |
| `20018` | 文件不存在或已删除 | 「种子文件找不到(可能已被清理),请重新上传 .torrent」 |
| `1000011` | 链接数量超过 115 个 | 本地已按 115 条切批,正常不应触发;触发则记 warning 并重切 |
| `990002` | 参数错误 | 「提交参数不被 115 接受」,附原始 message |
| `980004` | 操作失败 | 可重试,走退避 |
| 无权限/未开通 | —— | 「当前 115 应用未开通云下载权限」,并提示到开放平台申请(见风险 R1) |
### 5.4 实现陷阱(必须按此实现)
1. **`percentDone` 是驼峰**,`get_task_list` 其余字段是 snake_case。Go struct tag 必须写 `json:"percentDone"`,写错会永远 0 进度。
2. **`data[].info_hash` 仅任务成功受理时返回**,可能为空 → 必须同时保存提交时的原始 `url` 做回退匹配,并对 url 做归一化(去首尾空白、统一小写协议头、去掉 `&` 后跟踪参数?**不做过度归一化**,仅 trim,避免误合并)。
3. **`wanted` 索引基准未确认**(见 11 节实测项)。本期不做选文件,实现统一传 `wanted="0"`(官方文档唯一明确允许的取值),待真机确认后再决定是否改为全索引拼接。
4. **`add_task_urls` 单批 ≤115**,超了服务端报 `1000011`;本地必须切批。
5. **`wp_path_id=0` 表示根目录**,绝不能把「未选择目录」当 0 提交,否则文件落到 115 根目录,STRM 永远扫不到 → 提交前强校验 `WpPathID != "" && != "0"`。
6. **`get_task_list` 只支持 page,无 page_size**,需按 `page_count` 翻页;轮询设页数上限(默认 10 页)防止账号任务堆积把一轮轮询拖爆。
---
## 6. 为什么磁力不能选文件(结论留档)
| 接口 | 能否选择文件 | 依据 |
| :--- | :--- | :--- |
| `add_task_urls`(磁力/ed2k/http) | ❌ 无 wanted 参数 | `115doc/.../添加云下载链接任务.md` 请求参数仅 `urls`、`wp_path_id` |
| `torrent`(解析种子) | ✅ 返回 `torrent_filelist[].wanted` | `解析BT种子.md` 返回字段表 |
| `add_task_bt`(提交 BT) | ✅ `wanted` 必填 | `添加云下载BT任务.md` 请求参数表 |
`torrent` 的入参是 **`torrent_sha1` + `pick_code`**,只能解析「已上传到本人 115 空间的 .torrent 文件」,不接受 magnet / info_hash。开放平台没有「解析磁力 → 文件列表」这一步,因此磁力链路从入口就没有选文件能力。
**若要支持磁力选文件**,唯一路径是服务端自行把 magnet 抓成 .torrent(DHT metadata),属新增外部依赖与常驻网络行为,本期不做,记录为 P3 候选。
---
## 7. 后端实现
### 7.1 文件清单
**新增**
| 文件 | 内容 |
| :--- | :--- |
| `internal/model/cloud_download.go` | `CloudDownloadTask` 模型 + 状态常量 |
| `internal/repository/cloud_download_repository.go` | CRUD、按状态取活跃任务、按阶段取待推进任务、统计 |
| `internal/service/cloud115/offline.go` | 4+2 个 API 封装、响应结构体、错误码常量与 `OfflineError` 类型 |
| `internal/service/cloud_download.go` | `CloudDownloadService` 主体:构造、提交、取消、重试、列表、配额 |
| `internal/service/cloud_download_input.go` | 输入解析(多行/类型判定/去重/切批 ≤115)、匹配键归一化 |
| `internal/service/cloud_download_submit.go` | 链接提交 + `.torrent` 上传解析 → `add_task_bt`、暂存目录查找/创建 |
| `internal/service/cloud_download_poll.go` | 轮询、状态映射、超时熔断、远程任务与本地记录对账 |
| `internal/service/cloud_download_pipeline.go` | Stage 编排、去抖、等待同步、扫描、刮削、失败隔离 |
| `internal/service/scheduler_cloud_download.go` | `jobCloudDownloadPoll` |
| `internal/handler/cloud_download.go` | REST handler + multipart 种子上传 |
| `internal/handler/routes_cloud_download.go` | 路由注册(在 `registerAdminStrmRoutes` 同级新增 `registerAdminCloudDownloadRoutes`) |
| `web/src/types/cloudDownload.ts` | 前端类型 |
| `web/src/api/cloudDownload.ts` | 前端 API |
| `web/src/pages/CloudDownloadPage.tsx` | 页面(提交面板 + 任务列表 + 配额) |
**修改**
| 文件 | 改动 | 风险 |
| :--- | :--- | :--- |
| `internal/model/model.go` | `AllModels()` 追加 `&CloudDownloadTask{}` | 低(AutoMigrate 新增表) |
| `internal/repository/repository.go` | `Container` 增加 `CloudDownload *CloudDownloadRepository` | 低 |
| `internal/service/service.go` | `Container` 增加 `CloudDownload *CloudDownloadService`,`Start/Stop` 联动 | 低 |
| `internal/service/strm_service.go` | 新增 `SetSyncFinishHook`、`Open115Client` | 中(新增导出方法,不改现有逻辑) |
| `internal/service/strm_sync.go` | `finishSync` 末尾触发 hook(异步) | **中**:hook 里必须自带 recover + 超时,绝不能阻塞或 panic 影响同步协程 |
| `internal/service/service_builder.go` | 构造 `CloudDownload`(在 `Strm` 之后),注入 Strm/Scan/Scraper/Tasks/Hub;回调 hook | 低 |
| `internal/service/scheduler.go` | `Start()` 里 append `cloud_download_poll` job(仅在 `CloudDownload != nil` 时注册) | 低 |
| `internal/repository/library_repository.go` | 新增 `FindEnabledRootsByLocalPath(ctx, localPath)`(归一化 + 前缀匹配) | 低 |
| `web/src/appRoutes.tsx`、`web/src/components/layoutNavigation.ts` | 路由 + 侧边栏入口 | 低 |
### 7.2 服务对外 API
```go
type CloudDownloadService struct{ /* cfg, log, repos, strm, scanner, scraper, tasks, hub */ }
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error)
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentSubmit) (*CloudDownloadSubmitResult, error)
func (s *CloudDownloadService) List(ctx context.Context, q CloudDownloadQuery) (*CloudDownloadPage, error)
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error // 失败任务重新提交
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error // 仅重做后处理
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error)
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*OfflineQuota, error)
func (s *CloudDownloadService) Start(ctx context.Context) // 恢复中断任务
func (s *CloudDownloadService) Stop()
```
`CloudDownloadSubmit`:`AccountID`、`SyncPathID`、`URLs []string`(一次提交多条)。
`CloudDownloadSubmitResult`:逐条 `{source, task_id, ok, error}`,**部分成功必须返回 200 + 明细**。
### 7.3 提交校验(顺序不可乱)
1. `SyncPathID` 存在、`Enabled`、`Provider == cloud115`、`AccountID` 非空、`RemotePath` 为有效目录 ID 且 ≠ `"0"`;
2. 账号存在且 `Enabled`,`Open115Client` 可取得;
3. 配额预检:`get_quota_info` → `surplus <= 0` 时直接拒绝(批量多链接时按条数预估,无法精确预估大小,只做「配额是否耗尽」闸门);
4. 输入解析:按行拆分 → 类型判定 `magnet:?xt=urn:btih:` / `ed2k://` / `http(s)://` / `ftp://` → trim → 去重(同批内 + 与库内 `submitted/downloading` 任务去重);
5. 切批:每批 ≤115 条,逐批调用 `add_task_urls`;
6. 逐条落库:`data[].state=true` → `submitted`;失败 → `failed` + 错误码映射文案。
### 7.4 轮询实现要点
```
每 tick(默认 60s):
1. 取所有 status IN (submitted, downloading) 的任务,按 account_id 分组
2. 无活跃任务 → 直接返回(不打 115 接口)
3. 对每个账号:
翻页取 get_task_list(page=1..min(page_count, 10))
构建索引:info_hash(大写) → task,url(trim) → task
逐条匹配本地记录:
- info_hash 命中优先
- 否则 url 命中
- 都未命中 → 记 LastPollAt,未超时前不判失败(115 受理有延迟)
状态映射 + 更新 Percent/SizeBytes/Name
status=2 → succeeded + CompletedAt + Stage=pending
status=-1 → failed(Error 用 115 文案)
4. 超时:submitted/downloading 且 add_time 早于 now-72h → failed「下载超时」
```
### 7.5 Pipeline 实现要点
```
推进循环(可挂在同一 tick 之后,也可独立每 15s):
A. 取 status=succeeded AND stage IN (pending, waiting_sync, failed) 的任务,按 sync_path_id 分组
- 每组内若有任务 completed_at 在去抖窗口内(默认 30s)→ 本轮跳过(等凑批)
B. 对每个 syncPath:
- 若 IsSyncRunning(pathID) → 全部置 waiting_sync,下一轮再看
- 否则 StartSync(pathID, incremental),置 syncing,记录 StrmRecordID(取该 path 最新记录)
C. 同步完成后(hook):
- 置 scanning → 反查 library(LocalPath 与 LibraryRoot.Path 归一化后相等或为其子目录)
- 无匹配库 → Stage=na + StageError「本地目录未加入任何媒体库」
- ScanLibrary(libraryID) → ScanAdded
- 置 scraping → EnqueueLibrary(libraryID, ScrapeOptions{}) → ScrapeQueued
- 置 done
D. 任一步失败:Stage=failed + StageError,不重试(等待用户点「重做后处理」)
E. 进程重启:Start() 时把 syncing/scanning/scraping 的僵尸阶段重置为 pending(参考 StrmService.recoverInterruptedSyncs 的写法)
```
去抖细节:以 `syncPathID` 为单位,服务内存里维护 `lastTrigger[pathID] = time`;窗口内到达的任务全部 `waiting_sync`,窗口结束时由首个触发者发起同步。
### 7.6 STRM 完成回调
```go
// strm_service.go
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) { ... }
```
在 `finishSync`(`strm_sync.go:323`)**落库完成之后**调用:
```go
if hook := s.syncFinishHook(); hook != nil {
helper.Go(s.log, "strm.syncFinishHook", func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
hook(ctx, p.ID, status)
})
}
```
要点:**异步 + 自带 recover + 超时**,绝不能让 hook 的 panic 或阻塞影响同步协程;也不要在 `finishSync` 里同步调用扫描(同步耗时长,会拖住同步收尾与 Pending 记录)。
### 7.7 调度与配置项
设置项(沿用 `repo.Setting`,键名与现有 `scrape.*` / `strm.*` 风格一致):
| 键 | 默认 | 说明 |
| :--- | :--- | :--- |
| `cloud_download.poll_interval_sec` | `60` | 轮询间隔(30~600) |
| `cloud_download.sync_debounce_sec` | `30` | 同一同步目录的去抖窗口(0~300) |
| `cloud_download.auto_scrape` | `true` | 同步后是否自动刮削 |
| `cloud_download.task_timeout_hours` | `72` | 下载超时判定 |
| `cloud_download.torrent_max_mb` | `20` | `.torrent` 上传大小上限 |
| `cloud_download.max_pages` | `10` | 单轮轮询最大翻页数 |
`auto_scrape=true` 时**不受** `scrape.auto_on_scan` 全局开关约束(用户显式提交了下载,期望链路闭环);但若 `Scraper.AnyEnabled()==false`(无任何刮削源),Stage 直接置 `done` 并在 `StageError` 记「未配置刮削源」。
### 7.8 HTTP 路由与鉴权
挂在 `registerAdminStrmRoutes` 同组(`admin`,已带 `AuthRequired` + `AdminRequired`):
```go
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc)) // multipart
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
```
multipart 端点:`file`(.torrent)+ `account_id` + `sync_path_id`;实现需 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB<<20)`,并校验文件头(BT 种子是 bencode,以 `d` 开头,含 `announce` / `info`)。
---
## 8. 前端实现
### 8.1 页面结构(`CloudDownloadPage.tsx`)
**顶部:提交面板**
- 目标同步目录下拉:复用 `GET /admin/strm/paths`,只列 `provider=cloud115 && enabled`,展示 `name + remote_display_path + local_path`;
- 账号显示:由所选同步目录自动带出(`account_id`),无需二次选择;
- 链接文本框:多行粘贴,右下角实时显示「识别到 N 条:磁力 x / ed2k y / http z」;
- `.torrent` 拖拽/选择:单文件,提交时走 multipart 端点;
- 配额条:`surplus/count`,耗尽时提交按钮禁用并提示;
- 提交结果:逐条成功/失败明细(不合并成一句「提交失败」)。
**下方:任务列表**
- 列:来源(截断显示 + tooltip 全量)、目标目录、Size、下载进度、**阶段徽标**(下载中 / 等待同步 / 同步中 / 扫描中 / 刮削中 / 完成 / 失败)、时间、操作;
- 操作:取消(仅下载中)、重试(失败)、重做后处理(Stage 失败)、删除(终态);
- 批量:清空已完成;
- 刷新:复用现有 WS Hub(`hub.Publish("cloud_download", …)`),断线时降级为 5s 轮询。
### 8.2 路由与导航
- `appRoutes.tsx`:`{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true }`
- `layoutNavigation.ts`:`LAYOUT_NAV_ITEMS` 中 `STRM 管理` 之后加 `{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true }`
- `StrmManagePage` 顶部工具区加一个「云下载」跳转按钮,形成入口闭环。
---
## 9. 权限与安全
### 9.1 鉴权
首期仅管理员:路由挂在 `admin` 组,`AdminRequired` 中间件已覆盖。
### 9.2 输入安全
- 链接只做**字符串拼接转发**,服务端绝不主动发起对这些 URL 的请求(无 SSRF 面);
- `.torrent` 文件仅落临时目录 → 上传 115 → 删除,不本地解析 bencode(P1 若要预览文件名需加 bencode 解析,届时必须限大小与深度);
- 上传文件名禁用 `..` / 路径分隔符;
- `save_path` 本期固定为空(留字段),避免路径穿越争议。
### 9.3 后续(P2)
`model.UserPermission` 增加 `CanUseCloudDownload bool`,配套 handler 从 `AdminRequired` 换成权限中间件,并给普通用户做配额上限(否则一个用户可以把账号的 115 云下载配额打空)。
---
## 10. 测试计划
### 10.1 单元测试(必须)
| 目标 | 用例 |
| :--- | :--- |
| 输入解析 | 多行混合、空行、重复链接、首尾空白、大小写协议头、>115 条切批、非法行跳过并计数 |
| 状态映射 | status -1/0/1/2 → failed/downloading/downloading/succeeded;缺失 info_hash 时 url 回退命中;大小写 info_hash |
| 错误码映射 | 1000012 / 91006 / 20018 / 990002 文案与是否可重试 |
| 提交校验 | `RemotePath="0"`、账号禁用、同步目录禁用、配额耗尽 → 全部拒绝且不产生 115 请求 |
| 去抖 | 30s 内 3 个任务完成 → 只触发 1 次 `StartSync` |
| 等待同步 | `IsSyncRunning=true` → 全部 waiting_sync;结束后成功发起 |
| 阶段隔离 | 同步失败不改变 Status;扫描失败不改变 syncing 记录 |
| 僵尸恢复 | 重启后 syncing/scanning/scraping → pending |
测试风格沿用仓库现状:同包 `*_test.go`、`t.Context()`、httptest 模拟 115(参考 `internal/service/cloud115/cloud115_test.go` 的 mock 模式)。
### 10.2 集成测试
- `httptest` 起伪 115(`/open/offline/*`):验证提交 → 轮询 → 状态流转 → pipeline 触发(Strm 用 fake hook,Scanner/Scraper 用桩)。
- 现有测试回归:`go test ./internal/service/... ./internal/repository/... ./internal/handler/...`。
### 10.3 真机验收(不可省)
| # | 步骤 | 期望 |
| :--- | :--- | :--- |
| 1 | 提交一条小体积磁力(<200MB)绑定某 115 同步目录 | 任务 5s 内变 `submitted` |
| 2 | 等 115 完成 | 任务变 `succeeded`,随后 `syncing` |
| 3 | 同步完成 | 本地目录出现新 `.strm` 文件 |
| 4 | 自动扫描 | 媒体库新出现对应条目 |
| 5 | 自动刮削 | 条目 `scrape_status` 变 `matched`,海报正确 |
| 6 | 提交 `.torrent` 文件 | 同上,且 115 端任务名与种子内任务名一致 |
| 7 | 提交 ed2k 链接 | 同上(ed2k 无 info_hash,验证 url 回退匹配) |
| 8 | 提交 120 条链接 | 自动切 2 批,逐条结果完整 |
---
## 11. 待实测确认项
| # | 项 | 影响 | 处理 |
| :--- | :--- | :--- | :--- |
| 1 | `add_task_bt` 的 `wanted` 是 0-based 还是 1-based | 后续若开放选文件,索引拼接依赖此语义 | 真机用一个 3 文件种子验证;当前实现统一传 `"0"`,不受此影响 |
| 2 | `wanted="0"` 是「全部」还是「第 0 个」 | 决定 `.torrent` 任务是否只下一个文件 | **必测**:当前实现依赖它表示「全部」 |
| 3 | `MkDir` 对已存在目录的返回 | 暂存目录重复创建 | 先 `FindNamedContentInParent` 查找,未命中再 `MkDir`;两者都失败则回退到目标同步目录 |
| 4 | 当前 115 应用是否已开通云下载权限 | 全功能可用性 | 提交前 `get_quota_info` 探测;无权限时给出「去开放平台申请」指引 |
| 5 | 磁力整体提交时 115 默认勾选哪些文件 | 是否漏下样片/字幕 | 不影响链路,只在文档说明 |
| 6 | `get_task_list` 单页实际返回条数 | 翻页策略 | 首轮实测后调整 `max_pages` 默认值 |
---
## 11.5 实现状态(P0 已落地)
已完成(后端 + 前端最小可用):
| 层 | 文件 | 说明 |
| :--- | :--- | :--- |
| 模型 | `internal/model/cloud_download.go` | `CloudDownloadTask` + 状态/阶段常量,已注册 `AllModels()` |
| 仓储 | `internal/repository/cloud_download_repository.go` | 分页查询、活跃任务去重、阶段推进、僵尸阶段重置 |
| 115 协议 | `internal/service/cloud115/offline.go` | 7 个接口封装;`client.go` 新增 multipart 请求构造并复用限流/刷新/错误封装 |
| 服务 | `internal/service/cloud_download*.go` | 输入解析、提交、轮询、去抖、Pipeline(同步→扫描→刮削) |
| 调度 | `internal/service/scheduler_cloud_download.go` | `cloud_download_poll` 周期任务 |
| 打通 | `internal/service/strm_service.go` / `strm_sync.go` | `Open115Client` 复用缓存客户端;`SetSyncFinishHook` 异步回调 |
| HTTP | `internal/handler/cloud_download.go` | 9 个管理员接口 + multipart 种子上传 |
| 前端 | `web/src/pages/CloudDownloadPage.tsx` 等 | 提交面板、配额、任务列表、重试/取消/删除、5s 自动刷新 |
P0 范围外(保留为 P1/P2):
- 设置项 UI(当前通过 `Setting` 表键值生效,默认轮询 60s / 去抖 30s / 超时 72h);
- WebSocket 实时进度(当前为 5s 轮询);
- 普通用户权限位与单用户配额上限;
- 远端任务清理(`del_task` / `clear_task`)在 UI 上无入口(取消任务时会尽力调用 `del_task`)。
验证证据:
- `go build ./...`、`go vet` 通过;
- 新增单测:`internal/service/cloud115/offline_test.go`(7 项,含 multipart 格式与 `percentDone` 解析)、`internal/service/cloud_download_test.go`(9 项,含解析/去抖/目录匹配/校验);
- 前端 `tsc --noEmit`、`eslint`、`vite build` 通过;
- 3 个既有测试在本机 HEAD 上同样失败(Windows 文件占用与 SQLite `VACUUM INTO` 环境问题),已用独立 worktree 复核,与本次改动无关。
## 12. 分期与验收
### P0(可跑通,约 1 个迭代)
- 模型 + 仓储 + 迁移注册
- `cloud115/offline.go`(4 个接口:add_task_urls / get_task_list / get_quota_info / torrent + add_task_bt)
- 提交(磁力/ed2k/http)+ 轮询 + 状态机
- STRM finish hook + Pipeline(同步 → 扫描 → 刮削)
- 调度 job + 设置项
- 前端最小页:提交面板 + 任务列表(阶段徽标、重试、删除)
- **验收**:10.3 的步骤 1~5、7、8 通过
### P1(体验完善)
- `.torrent` 上传端点 + 全选提交(10.3 步骤 6)
- 配额条、提交结果逐条明细
- 去抖、僵尸恢复、超时熔断
- WS 实时进度
- 分页列表 + 筛选(状态/目录)
### P2(可选)
- 远端任务清理(`del_task` / `clear_task`)
- 完成后删除暂存 `.torrent`
- 普通用户权限位 + 单用户配额上限
- Telegram 完成通知
### P3(本期不做,记录候选)
- 磁力选文件(服务端 DHT 抓 .torrent)
- 完成后按 file_id 裁剪不需要的文件
---
## 13. 风险与回滚
| # | 风险 | 影响 | 缓解 |
| :--- | :--- | :--- | :--- |
| R1 | 115 应用未开通云下载权限 | 功能不可用 | 提交前探测 + 明确文案;不影响现有 STRM 功能 |
| R2 | 令牌双实例互相作废 | 账号被标「授权已失效」 | **强约束 K1**:只复用 StrmService 缓存的客户端 |
| R3 | 频繁同步打爆 CPU/115 限流 | 服务卡顿、风控 | 去抖 30s + 最小间隔 + 同步运行中等待 + throttle_manager |
| R4 | 目录错配(`wp_path_id` 不在同步目录) | 下了但永远扫不到 | 提交时强制 `RemotePath` 非空非 0;UI 只允许选同步目录,不放开自由选目录 |
| R5 | finish hook panic 拖垮同步 | 同步记录永远 running | hook 异步 + `helper.Go` + recover + 独立超时 |
| R6 | 新增表迁移失败 | 启动失败 | `AutoMigrate` 加表无破坏性;出问题回滚只需删表 + 移除注册 |
| R7 | 单页页数过多导致轮询耗时 | 轮询堆积 | `max_pages` 上限;超限记 warning 并下轮继续 |
**回滚方式**:移除路由与侧边栏入口即可(功能面隐藏);表可保留(不影响其它子系统),或手动 `DROP TABLE cloud_download_tasks`。
---
## 14. 附录:核对依据
| 结论 | 依据位置 |
| :--- | :--- |
| 115 开放平台离线接口形态与参数 | `115doc/115开放平台/API列表/云下载/*.md` |
| STRM 同步入口与运行互斥 | `internal/service/strm_sync.go:82-138` |
| 同步收尾无回调 | `internal/service/strm_sync.go:323`(`finishSync`) |
| 115 目录整树拉取成本 | `internal/service/strm_sync.go:682`(`walk115Flat`) |
| 令牌刷新落库与单实例要求 | `internal/service/strm_service.go:524-556`、`strm_115_oauth.go:438` |
| 115 provider 断言复用范式 | `internal/service/strm_sync.go:378`(`cloud.OpenAPI115Provider`) |
| 上传种子可拿 PickCode | `internal/service/cloud115/upload.go:95-102,217` |
| 刮削候选只挑 pending | `internal/service/scraper_library.go:183-207` |
| 扫描自动刮削开关 | `internal/service/scanner_prune.go:279`、`scanner_post_scan.go:19` |
| 周期任务注册范式 | `internal/service/scheduler.go:118-210` |
| 模型迁移注册 | `internal/model/model.go:37`、`internal/database/schema_migration.go:34` |
| 服务装配顺序 | `internal/service/service_builder.go:148` |
-190
View File
@@ -1,190 +0,0 @@
# 【开源推荐】MeBox:把 NAS / 网盘 / 远程 Emby 统一家里的观影入口,Docker 一键部署
> 配图已托管在 GitHub 仓库(`raw.githubusercontent.com` 直链),发帖时可直接引用,或下载 `docs/tutorial-screenshots/` 后作为附件上传。
---
## 写在前面
给论坛的朋友们推荐一个我维护的开源项目 —— **MeBox**,一个面向 NAS 与家庭影音场景的**自托管私人媒体中心**(GPL-3.0,Go + React)。
GitHub:https://github.com/truewhile/MeBox
一句话介绍:**部署一个服务,同时获得媒体库后台、网盘 STRM 整理、Emby 客户端协议网关三件套。** 内置完整 Emby/Jellyfin 服务端协议实现——手机、电视、平板上的 Infuse、SenPlayer、Fileball、Emby/Jellyfin 官方客户端直接「添加 Emby 服务器」就能连,一套账号体系全搞定,Emby 老用户零学习成本。
项目 fork 自 MediaStationGo 并持续二开,围绕网盘播放、任务队列、远程挂载和权限体系做了大量增强。
---
## 它能解决什么问题?
家里看电影电视的痛点,MeBox 基本一把梭:
| 痛点 | MeBox 的解法 |
| --- | --- |
| 硬盘散落各处,海报墙乱七八糟 | 多根目录媒体库 + TMDb/Bangumi/Douban 自动刮削,海报墙、继续观看、多季剧集一应俱全 |
| 网盘资源看一部下一部太麻烦 | OpenList / CloudDrive2 / 115 / WebDAV 接入,STRM 同步 + 直链/302 播放,不占本地空间 |
| 已经有一台 Emby,出门还得开 App | **远程 Emby 挂载**:把远程 Emby 的媒体库直接挂进 MeBox 界面统一浏览 |
| 家人乱动设置、小孩看不该看的 | 多用户 + 有效期 + 成人内容开关 + 播放配置 PIN,细粒度权限 |
| 每个设备装一套专属 App 太折腾 | **完整兼容 Emby/Jellyfin 客户端**:Infuse、SenPlayer、Fileball、官方客户端按「添加 Emby 服务器」填地址 + MeBox 账号即可,海报墙、观看进度、多用户直接同步 |
---
## 特点一览
**1. 现代化 Web UI,海报墙开箱即用**
![登录页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/01-login.png)
深色系登录页,默认账号 `admin / admin123`(首次登录请立即改密)。
![首页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/02-home.png)
首页自带焦点推荐轮播 + 媒体库入口卡片,继续观看、最近添加直接呈现。
**2. 媒体库与刮削**
![媒体库总览](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/03-libraries.png)
20 个媒体库、1600+ 条目一眼尽收:每库自带封面拼贴、条目数统计,支持「全库修复+重刮」「刮削队列」批量处理。
![海报墙](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/04-library-posters.png)
库内海报墙带评分、集数角标,支持按最后集添加日期排序,点开即看。
**3. 详情页与多季管理**
![详情页](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/05-media-detail.png)
剧情简介、类型标签、多季分集(特别篇/第 1-N 季)、每集缩略图与时长;一键立即播放、调用外部播放器、加入收藏。
**4. Emby/Jellyfin 客户端无缝兼容**
这是我最想强调的一点:**MeBox 内置了完整的 Emby 服务端协议实现**。手机、电视、平板上的 Infuse、SenPlayer、Fileball,甚至 Emby/Jellyfin 官方客户端,都不需要任何插件或改造——按「添加 Emby 服务器」填入 `http://服务器IP:18080`,用 MeBox 账号登录,海报墙、观看进度、收藏、多用户权限全部无缝衔接。已经习惯 Emby 生态的朋友可以零成本迁移,家人只用电视端 App 也完全无感。
**5. 网页播放器 + 弹幕自动匹配**
![播放器与弹幕](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/06-player-danmu.png)
内置网页播放器支持 HLS 转码、字幕、播放配置档;**弹幕按剧名自动匹配全季分集**(截图中自动匹配到《一拳超人》39 集),屏幕占比/透明度/字号随意调,追新番体验直接拉满。
**6. 网盘 STRM:网盘当本地盘用**
![STRM 管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/07-strm-cloud.png)
添加网盘账号(**115 支持二维码扫码登录**)→ 添加同步目录 → 系统把网盘/本地目录里的视频生成 `.strm` 文件,元数据经下载/上传队列双向同步,播放走直链/302 不落盘。
**7. 远程 Emby 挂载(特色功能)**
![Emby 挂载](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/08-emby-mount.png)
已有远程 Emby 服务器?填一次账号,按需勾选要挂载的媒体库(支持同服务器多线路自动切换、直连开关、排序),远程库直接出现在 MeBox 首页,不必再开 Emby 客户端。
**8. 任务队列统一管理**
![任务队列](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/09-task-queue.png)
刮削 / 下载 / 上传三类任务统一看板,排队中、进行中、已匹配、失败分类计数,支持搜索与批量清理。
**9. 下载与自动整理**
![文件管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/11-file-manager.png)
配合任意下载器(qBittorrent、Transmission 等下载到本地目录即可),MeBox 定时自动整理入媒体库:智能分类子库、自动注册目的地媒体库、复制/移动/硬链/软链多种整理方式,命名规则可配。
**10. 多用户与权限**
![用户管理](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/12-user-admin.png)
管理员/普通用户分级、单实例用户数上限、账号有效期、成人内容开关、播放配置 PIN——给家人开号放心给。
**11. 运维省心**
![系统设置](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/10-settings.png)
FFmpeg/FFprobe 一键下载安装、转码与硬件加速开关、TMDb 语言、识别词、弹幕、Adult/NSFW 开关全在设置页分组管理;另有 DLNA 投屏、存储统计、海报墙聚合视图:
![海报墙聚合](https://raw.githubusercontent.com/truewhile/MeBox/main/docs/tutorial-screenshots/13-poster-wall.png)
---
## 使用教程:从零到海报墙只要 5 步
### 第 1 步:Docker 一键部署
推荐 Docker Compose(仓库提供 4 份互相独立的完整模板,无需 `.env`):
```bash
mkdir -p MeBox && cd MeBox
# 最省心:单镜像 + 内置 SQLite
curl -fsSL https://raw.githubusercontent.com/truewhile/MeBox/main/docker-compose.simple.yml -o docker-compose.yml
# 多用户/大数据量可选 PostgreSQL 档、Redis 档、OpenSearch 档,见仓库 README「部署档位」
docker compose up -d
```
浏览器访问 `http://服务器IP:18080`,镜像:`ghcr.io/truewhile/mebox:latest`(amd64 / arm64 都有,也提供 Windows/Linux/macOS 单文件可执行程序,不想装 Docker 直接下载跑)。
### 第 2 步:登录并修改密码
默认账号 `admin / admin123`,登录后右上角头像 → 个人资料修改密码。
### 第 3 步:创建媒体库 + 扫库
后台 → 媒体库 → 管理媒体库,添加本地路径(Docker 部署记得填**容器内**路径,如 `/media/电影`,`volumes` 左侧挂宿主机真实目录)→ 执行扫库。
### 第 4 步:配置元数据刮削
系统设置 → 外部 API,填入 TMDb / Bangumi / Douban 等 API Key;媒体库页可对单个库「全库修复+重刮」,刮削进度在任务队列实时可见。
### 第 5 步(可选但强烈推荐):
- **网盘用户**:STRM 管理 → 添加网盘账号(115 可扫码)→ 添加同步目录 → 生成 STRM 后直链播放;
- **已有 Emby**:Emby 挂载 → 添加 Emby 账号 → 勾选要挂载的媒体库;
- **第三方播放器(Emby 客户端全兼容)**:Infuse / SenPlayer / Fileball / Emby、Jellyfin 官方客户端,按「添加 Emby 服务器」填 `http://服务器IP:18080`,用 MeBox 账号登录即可,原有使用习惯完全不变;
- **下载党**:qBittorrent 等任意下载器把视频下到下载目录,在文件管理里把它设为整理源,下完自动分类入库。
### 路径映射小抄(Docker 最常见坑)
```yaml
volumes:
- /vol1/1000/Media:/media # 左:宿主机真实路径;右:容器内路径(网页里填这个)
environment:
MEBOX_MEDIA_DIR: /vol1/1000/Media
MEBOX_MEDIA_CONTAINER_DIR: /media
```
硬链接要求同一文件系统/子卷,跨盘请改复制或软链。
---
## 部署档位怎么选?
| 档位 | 文件 | 组件 | 适合 |
| --- | --- | --- | --- |
| 极简 | `docker-compose.simple.yml` | 单镜像 + SQLite | 个人使用、低配设备 |
| 标准 | `docker-compose.yml` | + PostgreSQL | 多用户家庭共享 |
| 增强 | `docker-compose.standard.yml` | + Redis | 大媒体库高频访问 |
| 搜索 | `docker-compose.search.yml` | + OpenSearch | 超大库全文搜索 |
---
## 技术栈与致谢
- 后端:Go · Gin · GORM · SQLite/PostgreSQL · 可选 Redis / OpenSearch
- 前端:React 18 · Vite · TypeScript · Tailwind CSS · Zustand
- 部署:Docker Compose 多档模板,amd64/arm64 镜像 + 单文件可执行
感谢上游 [MediaStationGo](https://github.com/ShukeBta/MediaStationGo) 的奠基,网盘同步/STRM/整理部分参考了 [qmediasync](https://github.com/qicfan/qmediasync) 的思路。
---
## 链接
- GitHub:https://github.com/truewhile/MeBox
- Issue / PR:欢迎提 bug(附部署方式+复现步骤+日志)与功能建议
- License:GPL-3.0
觉得有用的话求个 Star ⭐,也欢迎论坛里的朋友反馈使用体验,我长期维护。
+91
View File
@@ -0,0 +1,91 @@
# MeBox Mobile API 接入与部署说明
更新日期:2026-10-17
适用版本:后端 `feature/android-mobile-api` 分支;App `0.1.0`(versionCode 1)。
## 1. 路径与版本
- 安卓契约挂载在 `/api/v1`,与既有 `/api`、Emby/Jellyfin 入口并行。
- 健康检查:`GET /api/health`。
- 实例身份:`GET /api/v1/server-info`(匿名,`Cache-Control: no-store`)。
- 契约字段与错误码见同目录 `openapi.yaml`。
`server-info.capabilities` 必须反映真实运行能力。当前分支只有
`device_sessions=true`;播放会话、阅读 v1 进度、批量正文与移动端转码仍为
`false`,App 不得据此进入对应流程。
## 2. 连接与传输安全
- 公网必须使用 HTTPS;App 默认使用系统信任链,不提供全局跳过证书校验。
- 局域网 HTTP 需要用户在 App 内明确确认风险;App 只对当前配置的服务器来源
使用明文,不会把 MeBox `Authorization` 发往其它主机。
- 首版只支持根路径部署。`https://host/mebox` 这类子路径会被 App 明确拒绝,
不会静默截断为根路径。
- 反代需要保留 `X-Request-ID`(可选);服务端会回传同值或新生成值,用于
日志对账。
## 3. 反向代理检查项
以下项用于媒体阶段(M3/M4)回归,当前登录闭环不依赖它们,但仍建议提前配置:
| 项目 | 要求 |
| --- | --- |
| Range / 206 | 透传客户端 `Range`,不要缓存完整响应后改写状态码 |
| 流式响应 | 关闭代理缓冲(如 nginx `proxy_buffering off`) |
| HLS | `.m3u8` 与分片路径不要被改写或强制压缩 |
| WebSocket | 升级头透传,读超时大于心跳间隔 |
| 超时 | 登录/普通 API 30 秒内完成;转码起播超时单独配置 |
目前只验证了普通 JSON 端点;上表其余项尚未在本分支验收。
## 4. 认证与会话
- `POST /api/v1/auth/login` 创建 `auth_sessions` 记录,访问令牌 JWT 增加 `sid`。
- 旧 `/api` 中间件同样校验 `sid`:设备被踢出或退出后,未过期的 JWT 也会立即
返回 401,不能借旧入口绕过。
- 没有 `sid` 的历史令牌继续按原逻辑访问旧 API;v1 认证端点拒绝这类令牌,
App 需要重新登录。
- `POST /api/v1/auth/logout` 幂等;会话撤销会同时撤销其名下刷新令牌。
- 设备管理中的踢出操作会同步撤销该设备认证会话。
### 刷新轮换边界
- 同一个刷新令牌的并发请求共享一次轮换;60 秒宽限期内重复提交返回同一结果。
- 轮换结果仍保存在进程内。若新令牌已签发、旧令牌已撤销,但客户端没收到响应
且服务器随后重启,旧令牌可能无法恢复;此时 App 应要求重新登录。首版接受
该边界,未实现持久化幂等轮换。
- 会话被撤销时,刷新返回 `401 SESSION_REVOKED`,优先于宽限期复用。
## 5. 错误契约
统一响应:
```json
{
"code": "SESSION_REVOKED",
"message": "auth session revoked or expired",
"request_id": "8a3bade4-cbb3-485b-8e34-2471e4940fc3",
"retryable": false
}
```
- 401 由 `code` 区分 `TOKEN_EXPIRED`、`SESSION_REVOKED`、`INVALID_CREDENTIALS`。
- 403 表示权限或账号状态,不触发刷新。
- 429 带 `Retry-After`,客户端必须等待。
- 5xx / `SERVICE_UNAVAILABLE` / `retryable=true` 表示可安全重试。
## 6. 数据迁移与回滚
- 新表 `auth_sessions` 与 `refresh_tokens.auth_session_id` 由现有 AutoMigrate
创建,属于增量变更,不清库。
- SQLite 已验证;PostgreSQL 迁移需要在有实例的环境中单独执行
`go test ./internal/database ./internal/repository -count=1` 后才能宣称通过。
- 回滚到旧版本不会删除新表;旧程序忽略新增字段。回滚前应先确认不需要再
依赖 `sid` 即时撤销能力。
## 7. 已知限制
- 单实例部署;未实现多副本共享会话。
- 外部播放令牌尚未继承 `sid`(属于 M3 播放会话范围),当前不能按设备即时撤销。
- 反代子路径、自签名证书信任、多服务器配置均未支持。
- App 目前只开放连接、登录、会话恢复与退出;媒体与阅读入口未实现。
+389
View File
@@ -0,0 +1,389 @@
openapi: 3.0.3
info:
title: MeBox Mobile API
version: 1.0.0
description: |
MeBox 安卓客户端的稳定接入契约。所有路径以 `/api` 为前缀挂载,
本文档描述 `/api/v1` 部分;既有 `/api` 与 Emby/Jellyfin 入口保持兼容。
约定:
- JSON 字段使用 snake_case;
- 时间点为 RFC 3339 UTC 字符串,播放位置与时长使用整数毫秒;
- 集合为空时返回 `[]`,未知字段客户端必须忽略;
- 错误响应统一为 `code`、`message`、`request_id`、`retryable`;
- 能力开关只有在接口实现且当前可运行时才为 true。
servers:
- url: /api
security:
- bearerAuth: []
tags:
- name: server
- name: auth
paths:
/v1/server-info:
get:
tags: [server]
summary: 匿名读取实例身份与移动端能力
security: []
responses:
'200':
description: 实例可连接
headers:
Cache-Control:
schema:
type: string
description: 固定为 no-store
content:
application/json:
schema:
$ref: '#/components/schemas/ServerInfo'
'503':
description: 暂时无法读取实例身份
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
/v1/auth/login:
post:
tags: [auth]
summary: 账号密码登录并创建认证会话
security: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
responses:
'200':
description: 登录成功
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
$ref: '#/components/responses/InvalidArgument'
'401':
$ref: '#/components/responses/InvalidCredentials'
'403':
description: 账号被禁用或已到期
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
'429':
$ref: '#/components/responses/RateLimited'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/refresh:
post:
tags: [auth]
summary: 使用刷新令牌轮换令牌对
security: []
description: |
刷新令牌必须由 v1 登录签发并关联认证会话。同一 refresh token 的
并发提交只会真正轮换一次;轮换后 60 秒内重复提交返回同一次结果。
会话被撤销后刷新返回 SESSION_REVOKED。
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RefreshRequest'
responses:
'200':
description: 刷新成功,新令牌继续归属同一 auth_session_id
content:
application/json:
schema:
$ref: '#/components/schemas/AuthResponse'
'400':
$ref: '#/components/responses/InvalidArgument'
'401':
description: 刷新令牌无效、过期或会话已撤销
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/session:
get:
tags: [auth]
summary: 读取当前用户与认证会话
responses:
'200':
description: 会话有效
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'401':
$ref: '#/components/responses/SessionInvalid'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/v1/auth/logout:
post:
tags: [auth]
summary: 撤销当前认证会话
description: 幂等;重复退出同样返回 200。
responses:
'200':
description: 已退出(本机与远端)
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
'401':
$ref: '#/components/responses/SessionInvalid'
'503':
$ref: '#/components/responses/ServiceUnavailable'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
RequestId:
name: X-Request-ID
in: header
required: false
description: |
可选的诊断 ID(8—128 位 ASCII 字母、数字或 `-_.:`)。服务器会在
响应头与错误体回传同一个值;非法值会被替换为新 UUID。该值不参与鉴权。
schema:
type: string
responses:
InvalidArgument:
description: 请求字段缺失或格式错误
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
InvalidCredentials:
description: 用户名或密码错误
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
SessionInvalid:
description: 访问令牌无效、过期或认证会话已撤销
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
RateLimited:
description: 请求过于频繁
headers:
Retry-After:
schema:
type: integer
description: 建议等待秒数
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
ServiceUnavailable:
description: 暂时性服务/存储错误,可安全重试
content:
application/json:
schema:
$ref: '#/components/schemas/MobileError'
schemas:
ServerInfo:
type: object
required:
- name
- server_id
- server_version
- api_version
- min_supported_app_version_code
- capabilities
properties:
name:
type: string
example: MeBox
server_id:
type: string
format: uuid
description: 实例持久身份;备份克隆成独立实例时必须显式重置。
server_version:
type: string
example: MeBox-v0.1.16
api_version:
type: string
example: v1
min_supported_app_version_code:
type: integer
minimum: 1
capabilities:
$ref: '#/components/schemas/ServerCapabilities'
ServerCapabilities:
type: object
required:
- device_sessions
- playback_sessions
- reader_progress_v1
- reader_content_batch
- local_transcode
properties:
device_sessions:
type: boolean
description: M1 起为 true
playback_sessions:
type: boolean
description: 播放会话接口未实现前必须为 false
reader_progress_v1:
type: boolean
description: v1 阅读锚点/版本冲突未实现前必须为 false
reader_content_batch:
type: boolean
description: v1 批量正文与资源引用未实现前必须为 false
local_transcode:
type: boolean
description: 移动端可用的本地转码会话未实现前必须为 false
MobileUser:
type: object
required: [id, username, role, tier, is_active, force_password_reset]
properties:
id:
type: string
username:
type: string
nickname:
type: string
role:
type: string
enum: [admin, user]
tier:
type: string
example: free
is_active:
type: boolean
force_password_reset:
type: boolean
expires_at:
type: string
format: date-time
nullable: true
TokenPair:
type: object
required: [access_token, refresh_token, expires_in, token_type]
properties:
access_token:
type: string
refresh_token:
type: string
expires_in:
type: integer
description: 访问令牌有效秒数
token_type:
type: string
example: Bearer
LoginRequest:
type: object
required: [username, password, device_id]
properties:
username:
type: string
password:
type: string
device_id:
type: string
maxLength: 128
description: App 安装随机生成,不使用 IMEI/MAC 等硬件标识。
device_name:
type: string
maxLength: 128
client_name:
type: string
maxLength: 64
example: MeBox Android
client_version:
type: string
maxLength: 64
RefreshRequest:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
AuthResponse:
type: object
required: [auth_session_id, user, tokens]
properties:
auth_session_id:
type: string
format: uuid
user:
$ref: '#/components/schemas/MobileUser'
tokens:
$ref: '#/components/schemas/TokenPair'
SessionDevice:
type: object
properties:
device_id:
type: string
device_name:
type: string
client_name:
type: string
client_version:
type: string
last_used_at:
type: string
format: date-time
expires_at:
type: string
format: date-time
SessionResponse:
type: object
required: [auth_session_id, user]
properties:
auth_session_id:
type: string
format: uuid
user:
$ref: '#/components/schemas/MobileUser'
session:
$ref: '#/components/schemas/SessionDevice'
MobileError:
type: object
required: [code, message, request_id, retryable]
properties:
code:
type: string
enum:
- INVALID_ARGUMENT
- INVALID_CREDENTIALS
- TOKEN_EXPIRED
- SESSION_REVOKED
- ACCOUNT_DISABLED
- ACCOUNT_EXPIRED
- PERMISSION_DENIED
- PROFILE_PIN_REQUIRED
- RATE_LIMITED
- UPSTREAM_UNAVAILABLE
- TRANSCODE_BUSY
- PLAYBACK_SESSION_NOT_FOUND
- RESOURCE_EXPIRED
- PROGRESS_CONFLICT
- SERVICE_UNAVAILABLE
message:
type: string
request_id:
type: string
retryable:
type: boolean
retry_after_sec:
type: integer
minimum: 0
details:
description: 可选的稳定结构化细节,未定义时客户端必须忽略。
+167
View File
@@ -0,0 +1,167 @@
# MeBox 阅读功能实施计划(legado 书源兼容)
> 分支:`feature/reading`
> 目标:在首页增加「影视 / 阅读」模式切换,阅读模式完整兼容阅读 3.0(legado)书源体系,覆盖 **文本(bookSourceType=0)、音频(=1)、漫画/图片(=2)** 三类源。
> 实现方式(用户明确要求):**样式与逻辑全部仿造 refgd/legado 本体**,不参考其他重实现项目;相当于用 Go + React 18 + TypeScript 5 重写该项目。
> 界面与交互的唯一规格:`docs/reader-ui-spec.md`(从 legado 源码逐屏调研产出)。
> 规则引擎的唯一语义基准:legado `app/src/main/java/io/legado/app/model/analyzeRule/` 源码,Go 侧逐方法移植对拍(源码克隆在 `C:\MyProject\_ref\legado`,仅作对照,不进入构建)。
## 1. 范围
**做:**
- 首页「影视/阅读」切换,阅读模式下有独立首页(书架/搜索/发现/最近阅读)
- legado 书源导入与管理(URL 导入、文本/JSON 粘贴导入、启停、分组、排序)
- 三类书源的完整链路:搜索 → 详情 → 目录 → 正文/播放列表/图片列表
- Go 侧规则引擎:CSS(jsoup 风格) / JSONPath / XPath / 正则 / 内嵌 JS 五种语法及其组合
- 文本阅读器、音频播放器、漫画阅读器三套前端 UI
- 追更、阅读进度同步(服务端存储,多端一致)、换源、替换净化规则
**不做(本期明确排除):**
- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示)
- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景;
纯 JS / 表单类登录已支持,见 P4.5)
- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3)
- 本地 TXT/EPUB 导入(列为后续可选)
## 2. 总体架构
沿用 MeBox 现有分层,全部新增代码集中在:
```
internal/
model/ # 新增 5 张表,注册进 AllModels() 自动迁移
repository/ # reader 相关 GORM 封装
service/reader/ # 规则引擎 + 书源业务(核心新增,预计占全部后端代码 70%)
handler/ # /api/reader/* 路由组
web/src/
pages/reader/ # 阅读端独立页面群(懒加载路由)
components/reader/
```
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
## 3. 规则引擎(核心工作)
语义基准:gedoor/legado `app/src/main/java/io/legado/app/model/analyzeRule/` 下的 AnalyzeRule / AnalyzeByJSoup / AnalyzeByJSonPath / AnalyzeByRegex / AnalyzeUrl,逐项对拍测试。
### 3.1 组件与选型
| 组件 | 选型 | 说明 |
|---|---|---|
| HTML/CSS | `PuerkitoBio/goquery` | jsoup 等价物;jsoup 特有语法(class.x / id.x / tag.x / text.x / children / @text / @textNodes / @html / 属性选择)自己包一层 |
| XPath | `antchfx/htmlquery` | 对齐 JsoupXpath 语义 |
| JSONPath | `PaesslerAG/jsonpath`(备选 ohler55/ojg) | Jayway 语义 + 自实现 `\|\|`/`&&` 合并层,选型阶段需验证 |
| 正则 | Go regexp(RE2) | legado 部分源用 Java 正则语法,回退换 `dlclark/regexp2` |
| JS | `dop251/goja` | ES2017+,跑书源内嵌 JS |
| 字符集 | `golang.org/x/text` | GBK/GB18030 解码 |
### 3.2 引擎能力清单
- 规则模式识别:`@css:` / `$.`(JSONPath) / `@XPath:`或`//` / `<js>`与`@js:` / `##` 正则替换段
- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}`
- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义)
- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`<js>` 生成 URL、URL 后 `,{...}` 选项(method/body/charset/headers/retry/timeout/type/proxy/js/webView)
- JS 沙箱:goja 运行时 + 执行超时中断 + 禁止直接 IO;上下文注入 `java`、`source`、`book`、`baseUrl`、`result` 等对象
- `java.*` 桥接函数(按书源实际使用频率分批实现):
- 网络:ajax / ajaxAll / connect / get / post / head
- 编解码:base64Decode/Encode(含 URL-safe)、hexDecode、encodeURI/decodeURI、htmlDecode
- 加解密:md5(16/32)、sha1/sha256、AES/DES/3DES/RSA(CBC/ECB + 常见 padding/key 语义,legado 源里最常见的坑)
- 字符串与时间:replaceAll/substring/正则族、timeFormat 等
- 规则回调:`java.getString/getElement` 等,桥回 Go 规则引擎(JS 与规则互相嵌套的关键)
### 3.3 兼容策略
- 引擎按能力分层实现,每个能力配真实书源样本的单测(fixtures 放 `internal/service/reader/testdata/`)
- 提供 CLI 冒烟工具(如 `cmd/reader-smoke`):对批量导入的公开书源集跑 搜索/详情/目录/正文 全链路,输出成功率报告,作为每个阶段验收依据
- 含 `webView` 选项的源直接判定不兼容并在书源管理页标注
## 4. 数据模型(新增表)
| 表 | 关键字段 |
|---|---|
| book_sources | name, group, type(0/1/2), source_url, json(原文), enabled, custom_order, last_check_at, comment |
| books(书架) | source_url, book_url, name, author, cover_url, intro, kind, type(文本/音频/图片), latest_chapter, total_chapters, last_read_chapter_index, last_read_at |
| book_chapters | book_id, index, title, url, is_volume, update_time |
| read_progress | book_id(唯一), chapter_index, position(滚动/秒/图片序), updated_at |
| replace_rules | name, find, replace, scope, is_regex, enabled, order |
阅读器显示设置(主题/字体/翻页方式)存前端 localStorage,不上服务端。
## 5. API 设计(/api/reader/*)
- 书源:`GET/POST/DELETE /sources`、`POST /sources/import`(URL 或 JSON/base64 文本,自动识别格式与类型)、`PATCH /sources/:id`(启停/排序)
- 搜索:`POST /search {keyword}` → 后台聚合任务,结果经 WS `reader:search` 增量推送;结果可一键加入书架
- 发现:`GET /explore?source=&group=`(解析 exploreUrl 的 `分组名::url` 结构)
- 书架:`GET/POST/DELETE /books`、`GET /books/:id/info`、`GET /books/:id/toc`、`POST /books/:id/refresh`(追更)
- 本地书籍:`POST /local/books`(multipart 上传 TXT/EPUB,导入即入书架)
- 内容:`GET /books/:id/chapters/:idx/content` —— 按书籍类型返回:
- 文本:`{type:"text", content:"..."}`(服务端已合并 nextContentUrl 翻页、已应用替换规则)
- 音频:`{type:"audio", tracks:[{url,title}]}`(含代理路径与所需请求头)
- 图片:`{type:"image", images:[{url, style}]}`(同样经代理)
- 进度:`PUT /books/:id/progress`
- 替换规则:`/replace-rules` CRUD
- 调试:`POST /debug {source_id, rule, url}`(书源调试器后端)
- 图片/流代理:复用现有 image_proxy / stream_proxy 模式,按源配置注入 UA/Referer
## 6. 前端设计
- **首页切换**:`HomePage.tsx` 顶部加分段控件(仿 `LibraryTagBar` tab 模式),「阅读」切到阅读首页;选择持久化(zustand + localStorage)
- **阅读首页**:继续阅读横排 + 书架封面网格 + 搜索入口 + 追更提示
- **页面群**(懒加载,仿 `appRoutes.tsx`):`/reader`(首页)、`/reader/search`(多源并发搜索 + 实时进度)、`/reader/explore`、`/reader/book/:id`(详情 + 目录 + 换源)、`/reader/sources`(书源管理 + 调试器)
- **三套阅读器**:
- 文本:滚动 + 分页双模式(CSS 分栏测量分页)、主题(含夜间)、字体/行距/边距、点击翻页区、章节预加载、进度上报
- 音频:`hls.js`(已是依赖)+ `<audio>` 兜底,播放列表、倍速、记忆进度、锁屏/息屏策略
- 漫画:上下滚动 / 左右翻页双模式、相邻图片预加载、`imageStyle` 支持
## 7. 阶段划分
| 阶段 | 内容 | 交付物 |
|---|---|---|
| P0 引擎地基 ✅ | 规则引擎核心(四分析器 + 规则拆分/组合/变量)+ AnalyzeUrl v1(GET/POST/charset/headers/变量/页码模式)+ 表结构 + 书源导入/管理 API + 搜索/详情/目录/正文/书架/进度/调试 API | 已完成:`internal/service/reader/rule/`(规则引擎,~2800 行,对齐 AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer)+ 服务层 + `/api/reader/*` 路由 + 单测/端到端测试全绿 |
| P1 文本源全链路 + 首页切换 | 搜索聚合(WS 进度)/详情/目录/正文(nextContentUrl 合并、缓存)+ 前端首页切换、书架、搜索、详情、文本阅读器 v1(阅读器样式仿 legado:9 宫格点击、主题、翻页动画) | 用纯规则型文本源完成「搜书→加入→阅读」全流程 |
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 听书面板(hls.js 播 m3u8、直链 `<audio>`、上一章/-15s/播放暂停/+15s/下一章、章节选择抽屉、定时关闭 0–180 分钟、倍速 0.5–3.0、跳过片头片尾按书持久化 `PUT /api/reader/books/:id/audio-config`、进度按秒记忆、播完/片尾到点自动下一章) | 音频源可听 |
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 |
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
真实缺陷是另外两个并已修复:
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
## 8. 风险与对策
- **书源质量参差**:单源解析全程超时与重试上限;正文翻页循环设页数上限防死循环
- **goja 性能**:复杂 JS 源解析慢 → 正文/目录强缓存、单源并发限 1、全局执行预算
- **Java 正则/JSoup 细节差异**:以对拍测试驱动修正,regex 备选 regexp2
- **热源防盗链**:图片与音频一律走带请求头的代理
- **合规**:不内置任何书源,全部由用户自行导入,管理页附免责说明
## 9. 参考
- **唯一语义与样式基准**:https://github.com/refgd/legado (规则引擎 analyzeRule 源码 + UI 布局/交互,见 docs/reader-ui-spec.md)
- 书源规则教程:https://mgz0227.github.io/The-tutorial-of-Legado/
+260
View File
@@ -0,0 +1,260 @@
# legado UI/交互仿制规格(React 18 + Tailwind Web 版实现依据)
> 来源:对 `refgd/legado`(克隆于 `C:\MyProject\_ref\legado`)源码的调研。
> 路径缩写:`<src>` = `app/src/main/java/io/legado/app/`,布局在 `app/src/main/res/layout/`。
## 1. 首页/书架
文件:`<src>ui/main/bookshelf/`(BookshelfFragment、BooksAdapterGrid/List)、`fragment_bookshelf1/2.xml`、`item_bookshelf_grid*.xml`、`item_bookshelf_list*.xml`。主界面底部 BottomNavigation(书架/发现/我的)+ 右上悬浮搜索按钮。
**布局结构(从上到下)**
- style1(标签页风格):标题行(书架标题 + 下拉箭头,点开分组切换菜单)→ 搜索按钮 + 更多按钮 → 一级分组 tab 条(tab 长按可删分组)→ 次级分组扩展标签 → ViewPager(每 tab 一个书籍列表/网格)。
- style2(单 RecyclerView 混排):title_bar → SwipeRefresh → 书籍列表(根分组时「分组网格项 + 书籍项」混排,进入分组只显示书籍)→ 空态文案。
- 网格项:封面 + 本地角标 + 未读数角标 + 加载中 + 书名 + 长按遮罩。
- 列表项:封面 + 本地图标 + 有更新标记 + 未读数 + 书名 + 作者 + 最后更新时间 + 当前读到(章节名)+ 最新章节(章节名)+ 长按遮罩。
**交互逻辑**
- 内置分组 id:全部(IdRoot=-100)、本地(IdLocal=-2)、未分组(IdUngrouped=-4),其余用户分组。
- 单击书 → 直接进入阅读;单击分组 → 进入该分组。
- 长按书 → 跳转书籍详情页;长按分组 → 重命名/删除对话框;style1 长按 tab 删除分组。
- 右上菜单:搜索、Wi-Fi 传书、刷新目录、书架布局切换、分组管理、导出/导入书架、下载离线、本地导入、网址添加、日志。
**MeBox 的「影视 / 阅读」首页模式**(`stores/readerSettings.homeMode` + 顶栏 `LayoutReaderModeToggle`)
- 切换入口在**顶栏**,位于搜索框与账号菜单之间,**只显示图标不显示文字**:影视模式显示场记板图标,阅读模式显示书图标(并带品牌色高亮),点一下切到另一个模块、图标随之变化。悬停提示写明「当前是 X 模式,点击切换到 Y」,不在首页时点击会先跳回 `/`。
- 两种模式共用一个首页路由 `/`:影视模式渲染媒体首页,阅读模式渲染书架(`ReaderHomeContent embedded`)。首页内容区不再放分段式「影视 / 阅读」开关(原 `ReaderModeSwitch` 仅留给独立布局的 `/reader` 首页兜底)。
- 切到阅读模式时,顶部搜索框换成**书搜索**(`LayoutHeaderBookSearch`,占位「搜索书籍…」):聚焦即下拉书架(本地即时过滤书名/作者,点条目进阅读器),回车优先打开书架首条;下拉底部固定一行「在书源中搜索「xxx」」,带 `?key=` 跳到 `/reader/search` 由多源聚合搜索页自动开搜。账号/主题菜单照常显示。
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
**阅读器偏好按账号同步(跨设备)**
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
## 2. 阅读界面(重点)
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
**布局结构(单 FrameLayout 栈)**:read_view 正文 → 文字选择光标 → read_menu 主菜单 → search_menu 全文搜索 → 朗读回原文浮条。
**正文渲染**
- ReadView 内含 3 个 PageView(prev/cur/next 缓存),ContentTextView 纯 Canvas 自绘:按字号/行距/段距/缩进/两端对齐排版,Web 版用 CSS multi-column 或 JS 分页等效实现。
- 翻页动画(0–5):0 覆盖、1 平移、2 仿真、3 上下滚动、4 无、5 平移覆盖;速度可调(默认 300ms)。
- 滚动模式支持背景跟随、自动翻页(定时滚动/定时翻页)。
- 图片/漫画书切换独立配置集 + MangaMenu:顶栏 + 底部胶囊(上一页/页码进度条/下一页),支持横滚、缩放。
**点击区域(9 宫格,边缘留 pageTouchClick px)**
- 区块:左上/中上/右上/左中/中/右中/左下/中下/右下。
- 默认:左列与上中=上一页;右列与下中=下一页;正中=呼出菜单。
- 全部可配动作:0 呼出菜单、1 下一页、2 上一页、3 下一章、4 上一章、7 书签、10 目录、11 搜索、12 同步进度等。
**主菜单 ReadMenu(自上而下)**
- 半透明遮罩(点击收起)。
- 顶部 title_bar:返回 + 书名(点击进详情)+ 章节名/章节链接(点击打开原页面)+ 书源按钮(弹菜单:编辑书源/禁用书源等)。
- 底部 bottom_menu(圆角面板):上一章 | 进度条 | 下一章 → 亮度条 + 自动亮度 → 快捷按钮行(搜索、自动翻页、替换规则、夜间/日间切换)→ 动作面板行(目录、朗读、界面、设置)。
- 菜单配色可跟随页面背景或用主题底色,透明度 35–100。
**设置面板**
- 「界面」三 Tab:文本(字体文件、字重、字号 5–50、字距、行距 0–20、段距、缩进、下划线、阴影);样式(白天/夜间/E-Ink 三态主题:文字色、背景色/图、强调色、菜单底色、菜单透明度、背景透明度,可恢复/管理/分享);页面(6 种翻页动画、状态栏深浅、滚动背景跟随、四边距、页眉页脚提示、简繁转换)。
- 「设置」:屏幕方向、保持亮屏、隐藏状态栏、两端/底部对齐、竖排、双页、进度条行为(按页/按章)、音量键/滚轮翻页、点击区域配置、动画速度、自动换源等。
- 页眉页脚提示项:无/章节名/时间/电量/电量百分比/页码/总进度/页码+总页/时间+电量/书名;页眉默认 左=时间/右=电量,页脚默认 左=章节名/右=页码/总页。
**内置主题与配色(assets/defaultData/readConfig.json,6 套 + 自定义)**
| 名称 | 背景(日/夜) | 文字(日/夜) | 强调(日/夜) | 字号 |
|---|---|---|---|---|
| 微信读书 | #FFC0EDC6 / #000000 | #FF0B0B0B / #ADADAD | #E53935 / #FE4D55 | 24 |
| 预设1 | #FFFFFF / #000000 | #000000 / #FFFFFF | #E53935 / #FE4D55 | 20 |
| 预设2(羊皮纸) | #DDC090 / #3C3F43 | #3E3422 / #DCDFE1 | #834E00 / #FE4D55 | 20 |
| 预设3(护眼绿) | #C2D8AA / #3C3F43 | #596C44 / #88C16F | #E53935 / #FE4D55 | 20 |
| 预设4(粉紫) | #DBB8E2 / #3C3F43 | #68516C / #F6AEAE | #801314 / #90BFF5 | 20 |
| 预设5(淡蓝) | #ABCEE0 / #3C3F43 | #3D4C54 / #90BFF5 | #E53935 / #FE4D55 | 20 |
默认排版:textSize 20、letterSpacing 0.1、lineSpacingExtra 12、paragraphSpacing 2、缩进「  」、padding 上下6/左右16、页脚线 true。未选样式时默认:bg #EEEEEE / 夜 #000000 / E-Ink #FFFFFF,文字 #3E3D3B / 夜 #ADADAD,强调 #E53935 / 夜 #FE4D55。
**MeBox 文本阅读器已实现的排版/菜单细节**(`ReaderViewPage.tsx`)
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
- 菜单打开时正文整体下移一个顶栏高度(用 `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)**
- 入口:书架页右上「本地导入」按钮(书架为空时另有「上传本地书籍」),支持 TXT / EPUB,单文件上限 64MB,上传后自动入库并直接进入阅读页。
- 书架卡片:本地书打「本地」角标,右上角有删除按钮(二次确认),删除会连服务器上的文件一起清掉。
- 存储:正文落盘 `data/reader/local/<bookID>.<txt|epub>`;目录信息与网络书共用 `reader_chapters`,用 `Tag` 记定位:TXT 存 UTF-8 规范化后文件内的字节区间 `start:end`,EPUB 存 zip 内的 XHTML 条目路径。读章只取所需区间/条目,不整本载入内存。
- TXT:自动识别 BOM(UTF-8/UTF-16) 与 UTF-8 / GBK / Big5,统一转 UTF-8 落盘;目录按 legado 默认 TXT 规则切章(`第X章/节/卷/集/部/篇`、序章、楔子、番外等),并对「第一章的正文内容」这类正文行做启发式过滤,切不出章名时整本当一章「全文」,章前内容(书名/简介)并入第一章。
- EPUB:`META-INF/container.xml` → OPF → `spine` 顺序出章。目录标题优先取 NCX / EPUB3 NAV,**逐 token 走并用栈收任意层级的 navPoint**——Epubor 等工具导出的 EPUB 常漏 `</navPoint>`,标题会整棵嵌进上一个节点,按固定层级解会丢掉一大半标题;取不到时依次退回正文首行 → 封面页(文件名含 cover 且该页只有图)标「封面」→ `<title>`(过滤 Cover/Table of Contents 这类无信息量的)→ `第 N 章`。
- EPUB 图片:正文里的 `<img>` 在转纯文本时就地换成 `[img]<zip 条目>` 标记行(相对路径按该 XHTML 所在目录解析),下发前把标记换成签名地址 `/api/reader/local/asset?b=&p=&s=`(HMAC,`<img>` 带不了 JWT),前端把 `[img]` 开头的行渲染成居中图片(`max-width:100%` + `max-height:70vh`,保证不撑破分栏)。网络书正文不受影响。
- 进度:与网络书同一套 `durChapter*` 字段,跨端一致;同名文件重复上传按覆盖更新处理(章数不变则保留进度)。
**音频书播放条(AudioPlayActivity + AudioPlayService,即「听书」)**
- 背景与封面:书籍封面强模糊(blur 32px)铺满做底,叠一层很淡的主题底色(opacity 0.3)保住日/夜对比度;正中圆形显示封面原图(对应 legado `upCover` 的 ivBg 模糊图 + ivCover 圆图)。封面缺失或加载失败时退回主题色圆点,不留破图。
- transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章(SEEK_STEP=15s,进度按秒)。
- 动作行(常驻底部,抽屉打开时仍可点):章节(目录选择,见下)、定时关闭、倍速、片头片尾。
- 章节选择:底部抽屉列全部章节,定位到当前章、当前章高亮、点章即跳;卷名行不可点。
- 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。
- 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。
- 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
## 3. 搜索
文件:`<src>ui/book/search/` + `<src>model/webBook/SearchModel.kt`。
- 布局:顶部 SearchView + 转圈进度条 → 结果流 → 书架命中提示卡 → 搜索历史 chips + 清空 → 右下 开始/停止 悬浮按钮。
- 结果项:封面、在书架角标、书源数角标(可换源数)、书名、作者、分类标签、最新章节、简介。
- 多源并发:所有启用书源(按搜索范围过滤),固定线程池并发(全局设置 threadCount),单源 30s 超时。
- 实时结果流:每源返回即刷新列表;结束后 onSearchFinish(isEmpty, hasMore)(任一源有结果即可翻页,searchPage++)。
- 聚合去重 mergeItems 四档:书名或作者等于关键词 > kind 含关键词 > 书名/作者含关键词 > 其他;同名同作者合并为一项并 addOrigin(角标显示可换源数);档内按书源数降序。
- 顶部菜单:精准搜索开关、搜索范围(分组/全选)、书源管理、日志。点结果 → 换源对话框/直接打开。
## 4. 发现
`<src>ui/main/explore/` + `ui/book/explore/`。布局:标题栏 → SwipeRefresh → 源选择行(当前源名 + 下拉、源内搜索、标签筛选、更多)→ 二级筛选条 → 分类标签条(横滑 chip,可展开)→ 书籍网格(封面+书名)→ 兜底列表。数据来自启用书源的 exploreUrl + ruleExplore;切源刷新标签;点标签拼 URL 加载、分页加载更多。
## 5. 书籍详情
`<src>ui/book/info/BookInfoActivity.kt`。布局:模糊封面背景 → 标题栏(返回/刷新/菜单)→ 下拉刷新 → 滚动区:封面 + 书名 + 作者 + 最新章节(点击刷新目录)+ 阅读时长 + 分类标签 + 分组行 + 书源行(换源)+ 目录入口行 → 双 Tab(简介 / 目录预览,可全屏、点章节跳读)→ 底部动作区:tv_shelf 加入/移出书架(描边按钮)+ tv_read 开始阅读/继续阅读(实心主色按钮)。菜单含置顶、去书源网页、登录、删除(勾选删缓存)、分享。
## 6. 目录页
`<src>ui/book/toc/`。布局:标题栏(返回 + 书名 + SearchView 搜章节)→ Tab(章节/书签)→ 章节 RecyclerView + 底部信息条(当前位置 + 跳顶/跳底)。菜单:倒序开关、使用净化替换、加载字数、长章分卷合并、TXT 目录规则。倒序即列表反转;卷名行可折叠;已读章节变色;点击章节回传 index 跳读。
## 7. 书源管理
- **主列表**:标题栏(SearchView 过滤 + 菜单)→ RecyclerView(item:域名、复选、启停 Switch、编辑、更多菜单、发现、快速调试、响应耗时条)→ 多选操作栏。菜单:排序(手动/自动/名称/URL/更新时间/响应时间/启用)、分组筛选(启用/禁用/需登录/无分组/启用发现/禁用发现)、添加书源(本地/网络/二维码)。
- **编辑**:标题栏(保存/调试入口)→ 类型下拉 + 六个开关(启用/启用发现/CookieJar/段评/事件监听/自定义按钮)→ 六个 Tab:基本/搜索/发现/详情/目录/正文,每 Tab 为「标签 + 输入框 + 帮助弹层」表单。
- **调试**:顶部 SearchView 输入关键词 → 串行执行 搜索→详情→目录→正文,日志逐条流式显示;可查看各阶段原始 HTML、切换探索分类调试。
## 8. 替换规则
- 列表页:标题栏(搜索 + 菜单:分组管理、启停筛选、添加、导入)→ RecyclerView(名称、分组、内容摘要、启停 Switch)→ 多选操作栏。
- 编辑页字段:规则名 → 分组 → 替换规则(+ 正则开关 + 帮助)→ 替换为 → 作用于标题 / 作用于正文 → 作用范围(书名) → 排除范围 → 超时毫秒(order 隐含)。
## 9. 数据实体字段全集
**BookSource.kt**(rule* 为内嵌对象)
| 字段 | 类型 | 默认值 |
|---|---|---|
| bookSourceUrl | String | "" |
| bookSourceName | String | "" |
| bookSourceGroup | String? | null |
| bookSourceType | Int (0文本/1音频/2图片/3文件/4视频) | 0 |
| bookUrlPattern | String? | null |
| customOrder | Int | 0 |
| enabled | Boolean | true |
| jsLib | String? | null |
| enabledExplore | Boolean | true |
| enabledCookieJar | Boolean? | true |
| concurrentRate | String? | null |
| header | String? | null |
| loginUrl / loginUi / loginCheckJs / coverDecodeJs | String? | null |
| bookSourceComment | String? | null |
| variableComment | String? | null |
| lastUpdateTime | Long | 0 |
| respondTime | Long | 180000 |
| weight | Int | 0 |
| exploreUrl / exploreScreen | String? | null |
| ruleExplore / ruleSearch / ruleBookInfo / ruleToc / ruleContent / ruleReview | 对象 | null |
| eventListener / customButton | Boolean | false |
**Book.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| bookUrl (PK) / tocUrl | String | "" / "" |
| origin / originName | String | localTag / "" |
| name / author | String | "" / "" |
| kind / customTag / coverUrl / customCoverUrl / intro / customIntro / charset | String? | null |
| type | Int (BookType) | text |
| group | Long | 0 |
| latestChapterTitle | String? | null |
| latestChapterTime / lastCheckTime | Long | now |
| lastCheckCount / totalChapterNum | Int | 0 |
| durChapterTitle | String? | null |
| durChapterIndex / durVolumeIndex / chapterInVolumeIndex / durChapterPos | Int | 0 |
| durChapterTime | Long | now |
| wordCount | String? | null |
| canUpdate | Boolean | true |
| order / originOrder | Int | 0 |
| variable | String? | null |
| readConfig(ReadConfig 内嵌) | reverseToc、pageAnim、reSegment、imageStyle、useReplaceRule、delTag、ttsEngine、splitLongChapter=true、readSimulating、startDate、startChapter、dailyChapters=3、openCredits=0、closeCredits=0、playMode=0、playSpeed=1.0、manga* 系列、mangaPageAnim | |
**BookChapter.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| url / title | String | "" / "" |
| isVolume | Boolean | false |
| baseUrl / bookUrl | String | "" / "" |
| index | Int | 0 |
| isVip / isPay | Boolean | false |
| resourceUrl / tag / wordCount | String? | null |
| start / end | Long? | null |
| startFragmentId / endFragmentId / variable / imgUrl | String? | null |
**ReplaceRule.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| id | Long | now |
| name | String | "" |
| group | String? | null |
| pattern | String | "" |
| replacement | String | "" |
| scope | String? | null |
| scopeTitle | Boolean | false |
| scopeContent | Boolean | true |
| excludeScope | String? | null |
| isEnabled | Boolean | true |
| isRegex | Boolean | true |
| timeoutMillisecond | Long | 3000 |
| order | Int | Int.MIN_VALUE |
## 10. 主题与视觉
- 全局:主背景 `#F7F7FA`、主文字 `#DE000000`、品牌色 `#FFF6FBF8`(浅绿白调)。
- 界面主题由 ThemeConfig(themes.json)控制 6 套;阅读菜单/底栏用 bottomBackground(跟随所选界面主题),沉浸模式(readBarStyleFollowPage)直接取页面背景/文字色。
- 强调色 accentColor 用于选中态、光标、Tab 选中;E-Ink 模式菜单改描边。
- 视觉风格:大圆角面板、1dp 描边、玻璃拟态半透明条、图标+文字标签的动作面板。
## Web 版实现要点
- 正文分页仿 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/字段一一对应。
-743
View File
@@ -1,743 +0,0 @@
# 阅读模块(Reading Module)设计与实施方案
> 状态:设计稿,待评审
> 目标版本:v0.2.0(分期落地,见第 9 节)
> 关联现有子系统:媒体库 / 网盘存储 / 权限体系 / 任务队列
---
## 1. 目标与范围
### 1.1 已确认的产品决策
| 维度 | 决策 |
| --- | --- |
| 内容类型 | **电子书 + 漫画统一书架**(EPUB / TXT / PDF / MOBI 与 CBZ / CBR / 图片文件夹) |
| 书源 | **独立书库**(不复用影视媒体库)+ **网盘直链阅读** |
| 首版范围 | **完整版**:多用户书库权限 + 阅读统计 |
| 阅读形态 | **滚动流式与分页翻页双模式**,用户可切换并持久化偏好 |
### 1.2 明确的非目标
- **不接入 Emby/Jellyfin 协议。** Emby 的 `Items` / `Views` / `PlaybackInfo` 语义围绕音视频构建,没有书籍章节与阅读进度的对应概念。强行映射会污染 `internal/service/emby_*.go` 与 `internal/handler/emby_*.go` 的既有兼容层,收益极低。阅读能力只通过 MeBox 自己的 Web UI 提供。
- **不复用 `model.Library` / `LibraryRoot`。** `Library.Type` 的取值域是 `movie/tv/anime/music`,且被海报墙轮播(`CarouselEnabled`)、自动整理管线、Emby 视图、首页预览等链路消费。把书库塞进去会导致这些链路需要到处加 `type != "book"` 判断。
- 首版不做:听书 TTS、在线书源(笔趣阁类)、社交分享、跨设备同步批注冲突合并。
---
## 2. 总体架构
### 2.1 分层落位
完全沿用现有分层,不引入新模式:
```
web/src/pages/Books*.tsx ← 页面
web/src/components/Book*.tsx ← 阅读器与书架组件
web/src/api/books.ts ← axios 封装(仿 web/src/api/library.ts)
↓ /api/books/*
internal/handler/books*.go ← 反序列化 + 权限校验 + 响应
internal/service/book_*.go ← 业务策略(扫描、解析、进度、统计)
internal/repository/book_*.go ← 纯持久化
internal/model/book.go ← GORM 模型,注册进 model.AllModels()
```
新增路由注册走 `internal/handler/routes_authenticated_features.go` 的既有范式,新增一个 `registerAuthedBookRoutes(authed, svc)`,在 `registerAuthenticatedRoutes` 链上挂载。`service.Container` 与 `repository.Container` 各追加一个字段。
### 2.2 与现有能力的复用点
| 现有部件 | 复用方式 |
| --- | --- |
| `service.StreamService.ServeFile`(`internal/service/stream_file.go`) | 已用 `http.ServeContent` 处理 HEAD / Range / If-Modified-Since,**PDF 与原始文件流直接照搬这条路径** |
| `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 |
| `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** |
| `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 |
| `service.PruneImageCache`(`internal/service/cache_cleanup.go`) | 现成的「按总大小做 LRU 淘汰」助手,书籍缓存淘汰直接复用它 |
| `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 |
| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `MaxDiskUsageMB` / `TTLHours` / `AutoCleanup` / `CleanupIntervalMin`,书籍缓存容量配置直接挂进去 |
| `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` |
| `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) |
| `model.UserPermission` | 新增阅读权限位,见第 7 节 |
| `helper.Go` / `Container.stopCtx` | 后台扫描任务的生命周期管理 |
---
## 3. 数据模型
新增文件 `internal/model/book.go`,并在 `internal/model/model.go` 的 `AllModels()` 中追加。所有表继承 `model.Base`(UUID 主键 + 时间戳 + 软删除)。
**表名约定**:`internal/model` 全包**没有任何 `TableName()` 覆盖**,一律使用 GORM 默认复数化(例如 `PlaybackHistory` → `playback_histories`,可从 `internal/database/schema_migration.go` 的裸 SQL 印证)。新表沿用该约定,不引入例外。因此模型命名要保证复数化结果干净:
| 模型 | 表名 |
| --- | --- |
| `Book` | `books` |
| `BookLibrary` | `book_libraries` |
| `BookSource` | `book_sources` |
| `BookChapter` | `book_chapters` |
| `BookProgress` | `book_progresses` |
| `BookAnnotation` | `book_annotations` |
| `BookFavorite` | `book_favorites` |
| `BookReadingSession` | `book_reading_sessions` |
| `BookDailyStat` | `book_daily_stats` |
(刻意用 `BookDailyStat` 而不是 `BookStatDaily`——后者复数化会得到 `book_stat_dailies`。)
### 3.1 书库与书源
```go
// BookLibrary 是独立于影视媒体库的书库。
type BookLibrary struct {
Base
Name string `gorm:"size:128;not null" json:"name"`
Kind string `gorm:"size:16;not null;default:mixed" json:"kind"` // ebook / comic / mixed
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"index;default:0" json:"sort_order"`
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
ScanStatus string `gorm:"size:16;default:idle" json:"scan_status"` // idle / scanning / error
ScanMessage string `gorm:"size:512" json:"scan_message,omitempty"`
}
// BookSource 是书库下的一条挂载来源:本地目录或网盘路径。
type BookSource struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
Name string `gorm:"size:128" json:"name,omitempty"`
StorageKind string `gorm:"size:16;not null;default:local" json:"storage_kind"` // local / cloud
Path string `gorm:"size:1024;not null" json:"path"` // 本地绝对路径 / 网盘内路径
StorageConfigID string `gorm:"index;size:36" json:"storage_config_id,omitempty"` // 复用 model.StorageConfig
Depth int `gorm:"default:3" json:"depth"` // 扫描递归深度上限
Enabled bool `gorm:"default:true" json:"enabled"`
SortOrder int `gorm:"default:0" json:"sort_order"`
}
```
`StorageKind = cloud` 时,`StorageConfigID` 指向一条 `StorageConfig`(`Type` ∈ `cloud115 / clouddrive2 / openlist / emby_remote`)。凭据解密沿用 `service.CryptoService`。
### 3.2 书籍与章节
```go
type Book struct {
Base
LibraryID string `gorm:"index;size:36;not null" json:"library_id"`
SourceID string `gorm:"uniqueIndex:uniq_book_source_path,priority:1;index;size:36;not null" json:"source_id"`
// SourcePath 在本地源是绝对路径,在网盘源是「网盘内路径」,两者都用
// (source_id, source_path) 做唯一键,天然隔离两个 ID 空间。
SourcePath string `gorm:"uniqueIndex:uniq_book_source_path,priority:2;size:1024;not null" json:"source_path"`
SourceRef string `gorm:"size:256" json:"source_ref,omitempty"` // 网盘 file id / pickcode
Title string `gorm:"size:512;not null" json:"title"`
Author string `gorm:"size:256;index" json:"author,omitempty"`
SeriesName string `gorm:"size:256;index" json:"series_name,omitempty"`
Volume int `json:"volume"`
Format string `gorm:"size:16;not null" json:"format"` // epub/txt/pdf/mobi/cbz/cbr/folder
MediaKind string `gorm:"size:16;not null;default:ebook" json:"media_kind"` // ebook / comic
SizeBytes int64 `json:"size_bytes"`
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"` // 大小+首尾采样,去重
CoverURL string `gorm:"size:1024" json:"cover_url,omitempty"`
Description string `gorm:"type:text" json:"description,omitempty"`
Language string `gorm:"size:32" json:"language,omitempty"`
Tags string `gorm:"type:text" json:"tags,omitempty"` // 逗号分隔
ChapterCount int `json:"chapter_count"`
WordCount int64 `json:"word_count"`
PageCount int `json:"page_count"` // 漫画总页数 / PDF 页数
ParseStatus string `gorm:"size:16;default:pending" json:"parse_status"` // pending/ok/failed
ParseMessage string `gorm:"size:512" json:"parse_message,omitempty"`
NSFW bool `gorm:"default:false" json:"nsfw"`
AddedAt time.Time `json:"added_at"`
}
```
**唯一键说明**:`SourcePath` 上的 `uniqueIndex` 需与 `SourceID` 组成复合键(`uniq_book_source_path`,priority 1 = `source_id`)。同一本书被两个书源包含时允许重复入库,这是符合预期的(用户可能故意如此)。
```go
// BookChapter 只存索引,不存正文(见 3.4 的取舍)。
type BookChapter struct {
Base
BookID string `gorm:"index:idx_book_chapter,priority:1;size:36;not null" json:"book_id"`
Index int `gorm:"index:idx_book_chapter,priority:2" json:"index"`
Title string `gorm:"size:512" json:"title"`
Level int `gorm:"default:1" json:"level"` // 目录嵌套层级,1 = 顶级
// 电子书定位:二选一
Href string `gorm:"size:1024" json:"href,omitempty"` // EPUB zip 内条目路径
StartOffset int64 `json:"start_offset"` // TXT 字节区间
EndOffset int64 `json:"end_offset"`
// 漫画/PDF 定位
PageStart int `json:"page_start"`
PageEnd int `json:"page_end"`
CharCount int `json:"char_count"`
}
```
### 3.3 进度、批注、收藏、统计
```go
// BookProgress 每个用户每本书一行(复合唯一键,仿 model.PlaybackHistory 的 uniq_user_history 模式)。
type BookProgress struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `gorm:"default:0" json:"chapter_index"`
ChapterTitle string `gorm:"size:512" json:"chapter_title,omitempty"`
CharOffset int `json:"char_offset"` // 章内字符偏移(电子书)
PageIndex int `json:"page_index"` // 页码(漫画 / PDF)
Percent float64 `json:"percent"` // 全书百分比,书架进度条展示用
ScrollRatio float64 `json:"scroll_ratio"` // 章内滚动比例,跨端还原更精确
ReaderMode string `gorm:"size:16;default:scroll" json:"reader_mode"` // scroll / paged
Finished bool `gorm:"default:false" json:"finished"`
TotalSeconds int64 `json:"total_seconds"`
LastReadAt time.Time `gorm:"index" json:"last_read_at"`
}
type BookAnnotation struct {
Base
UserID string `gorm:"index:idx_book_anno,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index:idx_book_anno,priority:2;size:36;not null" json:"book_id"`
ChapterIndex int `json:"chapter_index"`
Type string `gorm:"size:16;not null" json:"type"` // bookmark / highlight / note
StartOffset int `json:"start_offset"`
EndOffset int `json:"end_offset"`
SelectedText string `gorm:"size:2048" json:"selected_text,omitempty"`
Note string `gorm:"type:text" json:"note,omitempty"`
Color string `gorm:"size:16" json:"color,omitempty"`
}
type BookFavorite struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"uniqueIndex:uniq_user_book_fav,priority:2;size:36;not null" json:"book_id"`
}
// BookReadingSession 由前端心跳驱动,服务端按小时聚合,避免行数爆炸。
type BookReadingSession struct {
Base
UserID string `gorm:"index:idx_book_stat,priority:1;size:36;not null" json:"user_id"`
BookID string `gorm:"index;size:36;not null" json:"book_id"`
BucketStart time.Time `gorm:"index:idx_book_stat,priority:2" json:"bucket_start"` // 截断到小时
Seconds int64 `json:"seconds"`
CharsRead int64 `json:"chars_read"`
PagesRead int `json:"pages_read"`
}
// BookDailyStat 每日汇总,供热力图与「年度阅读报告」查询,避免实时扫 session 表。
type BookDailyStat struct {
Base
UserID string `gorm:"uniqueIndex:uniq_user_book_daily,priority:1;size:36;not null" json:"user_id"`
Day string `gorm:"uniqueIndex:uniq_user_book_daily,priority:2;size:10;not null" json:"day"` // YYYY-MM-DD
Seconds int64 `json:"seconds"`
Chars int64 `json:"chars"`
Pages int `json:"pages"`
Books int `json:"books"` // 当日有阅读记录的书数
}
```
### 3.4 关键取舍:正文不入库
**决策:DB 只存章节索引(偏移量 / zip 内路径 / 页码区间),正文按需从源文件读取。**
理由:
1. 网文 TXT 常见 5–50MB,漫画单册 100–800MB。入库会让 SQLite 单文件膨胀到数十 GB,直接冲击 `docker-compose.simple.yml` 的「单文件数据库好备份」定位,也会拖慢全库 VACUUM / 备份 / 数据库迁移(`internal/service/database_admin.go`)。
2. 源文件本来就是权威副本,重复存储没有收益。
3. EPUB 与 CBZ 本质上都是 zip,**随机读取 zip 内单个条目成本极低**(读中央目录 + 解压目标条目),不需要把整本解压落盘。
代价是每次打开章节都要读源文件。缓解手段:
- 本地源:`os.Open` + `io.SectionReader`,代价可忽略。
- 网盘源:见 4.3 的本地缓存策略,且对已缓存的章节走本地。
### 3.5 用户级字段(挂在 `model.User` 上)
沿用 `AllowedLibraryIDs` 的 JSON-in-text 模式(见 `internal/model/user.go`),**不复用影视库字段**,避免两个 ID 空间交叉:
```go
// 追加到 model.User
ReaderSettings string `gorm:"type:text" json:"-"` // 阅读器偏好 JSON
AllowedBookLibraryIDs string `gorm:"type:text" json:"-"` // 空 = 不限制
AllowedBookLibraryList []string `gorm:"-" json:"allowed_book_library_ids,omitempty"`
```
`ReaderSettings` 结构(前端读写,服务端仅透传与长度校验):
```json
{
"mode": "scroll|paged",
"fontSize": 18,
"lineHeight": 1.8,
"fontFamily": "serif|sans|custom",
"contentWidth": 720,
"theme": "light|sepia|dark|black",
"pageAnimation": "slide|fade|none",
"comicLayout": "single|double|auto",
"comicDirection": "ltr|rtl",
"hideScrollbar": true
}
```
放在 `User` 行内(而非新表)的理由:与 `PlayerVolume` / `DanmakuFontSize` 等既有播放器偏好一致,读取时随用户信息一并返回,无需额外查询。
---
## 4. 书源与内容读取管线
### 4.1 扫描流程
```
POST /api/books/libraries/:id/scan
→ BookScannerService.ScanLibrary(ctx, libraryID)
1. 置 ScanStatus=scanning,通过 SSEHub 广播进度(复用 service.SSEHub)
2. 遍历启用的 BookSource
- local: filepath.WalkDir,按扩展名白名单过滤,超过 Depth 停止递归
- cloud: cloud.New(cfg.Type, cfg, client).List(ctx, dirID) 递归列目录
3. 对每个候选文件调 BookParser.ParseMeta(reader) 拿元信息 + 目录
4. Upsert 到 books / book_chapters(source_id + source_path 为幂等键)
5. 源上已消失的书标记软删除(与影视库扫描语义保持一致)
6. 置 ScanStatus=idle,记录 LastScanAt
```
扩展名白名单:`.epub .txt .pdf .mobi .azw3 .cbz .cbr .zip .rar`(`.zip/.rar` 仅当目录内全是图片时按漫画处理,否则跳过,防止误吞压缩包)。
并发:复用 `internal/service` 现有的 worker 池写法,默认 2–4 并发解析(解析要读文件,IO 密集)。
### 4.2 各格式解析策略
| 格式 | 元信息 | 章节 / 页 | 正文读取 |
| --- | --- | --- | --- |
| **EPUB** | zip → `META-INF/container.xml` → OPF → `dc:title/dc:creator/dc:language/dc:description`;封面取 OPF `meta[name=cover]` 指向项,退化到 `guide` | 按 spine 顺序,标题取每个 XHTML 的 `<title>` 或首个 `h1..h3`;`Level` 由 nav/ncx 的嵌套深度推断 | `archive/zip` 定位 `Href` 条目,读出 XHTML → 服务端清洗后返回 |
| **TXT** | 文件名(`书名 - 作者.txt` 模式)+ 编码探测 | 正则切分:`第[一二三四五六七八九十百千零两0-9]+[章节卷回篇]`、`Chapter\s+\d+`、`^\s*\d+\s*$`;命中不足 3 个则按固定字节窗口切片 | `io.SectionReader` 读 `[StartOffset, EndOffset)` → 按探测到的编码转 UTF-8 |
| **PDF** | 首页/元数据(页数、标题);封面渲染首屏,失败则留空 | 单章「正文」,`PageStart/PageEnd` = 全书页 | 原始文件流(Range),前端 pdf.js 自己解析 |
| **CBZ / CBR** | zip/rar 条目自然排序,第一张图做封面 | 单章,页区间 = 图片条目序号 | 按页解压单条目,走图片响应路径 |
| **图片文件夹** | 目录名 | 单章,页区间 = 排序后图片序号 | 直接读本地文件 |
| **MOBI / AZW3** | PalmDOC / KF8 头 | 首版**只入库展示、不支持在线阅读**,详情页给出「下载原文件」入口 | — |
实现细节提示:
- 编码探测用 `golang.org/x/text`(已是 `go.mod` 间接依赖)。GBK/Big5/UTF-16LE 都要覆盖,中文网文 TXT 大量是 GBK。
- CBR 需要 RAR 解压。建议引入纯 Go 的 `github.com/nwaples/rardecode`;若不接受新依赖,首版把 CBR 归入「只入库、不可读」。
- EPUB XHTML 清洗**必须在服务端做**:剔除 `<script>`、`on*` 事件属性、`<iframe>`、外部 `http(s)` 资源引用,把 `src/href` 重写为 `/api/books/:id/res/*`。前端再叠一层 DOMPurify 作为纵深防御。
### 4.3 网盘书籍的读取策略
网盘直链的核心约束:**EPUB / CBZ 的解析必须能读到文件尾部**(zip 中央目录在末尾),但 `cloud.Provider.Resolve` 返回的是短时效 URL,且 115 直链依赖 UA/Cookie(`DirectLink.Headers`),浏览器无法直接携带。
因此分两条路径:
**A. 解析阶段 —— 完整拉取到缓存目录**
```
<CacheDir>/books/<sourceID>/<hash>.<ext>
```
`BookParser` 通过 `DirectLink` 拉全量文件到缓存后再解析。缓存目录复用 `config.CacheConfig.CacheDir`(默认 `<DataDir>/cache`,容器里是 `/cache`),容量上限新加一项 `CacheConfig.BooksMaxSizeMB`(默认 2GB),走 LRU 淘汰。缓存命中的书后续正文读取也直接走本地,不再回网盘。
**B. 阅读阶段 —— 优先本地缓存,未命中走代理流**
未缓存时由服务端反代目标 URL(`DirectLink.Proxy=true` 时同样反代),并把 `Content-Type: image/*` 或 `application/pdf` 透传给前端。反代实现直接参照 `internal/service/cloud115_hls_proxy.go` 的响应头透传白名单(`Content-Type/Content-Length/Content-Range/Accept-Ranges/ETag/Last-Modified`)。
**C. 阅读进度与文件解耦** —— 代码里区分「源」「位置」:
```go
type BookLocator struct {
Kind string `json:"kind"` // local / cloud
LocalPath string `json:"local_path,omitempty"`
CloudConfig string `json:"cloud_config,omitempty"`
CloudRef string `json:"cloud_ref,omitempty"`
Href string `json:"href,omitempty"` // zip 内条目
StartOffset int64 `json:"start_offset,omitempty"`
EndOffset int64 `json:"end_offset,omitempty"`
}
```
被缓存的书 `Kind` 仍报 `cloud`(进度不绑物理位置),这样缓存被淘汰后进度依然有效。这是不把 `Book.Path` 直接存成本地缓存路径的原因。
### 4.4 磁盘与容器
书籍目录需要在 compose 里挂载,并在 README 的部署档位表补充说明。新缓存目录复用现有 `MEBOX_CACHE_CACHE_DIR`(`docker-compose.simple.yml` 中为 `/cache`),无需新增环境变量。
---
## 5. HTTP API 设计
全部挂在 `/api/books/*`,注册在 `registerAuthedBookRoutes`。响应统一走 `internal/handler/response.go` 的既有助手。
### 5.1 书库与扫描(管理端)
| 方法 | 路径 | 权限 | 说明 |
| --- | --- | --- | --- |
| GET | `/api/books/libraries` | `can_read_books` | 列表,按 `AllowedBookLibraryIDs` 过滤可见性 |
| POST | `/api/books/libraries` | `can_manage_book_library` | 新建/更新书库 |
| DELETE | `/api/books/libraries/:id` | `can_manage_book_library` | 删除(含级联软删 books) |
| GET | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 书源列表 |
| POST | `/api/books/libraries/:id/sources` | `can_manage_book_library` | 新增书源(本地目录 / 网盘路径) |
| POST | `/api/books/libraries/:id/scan` | `can_manage_book_library` | 触发扫描,返回 task id |
| GET | `/api/books/scan/status` | `can_manage_book_library` | 扫描进度轮询 |
| GET | `/api/books/browse` | `can_manage_book_library` | 网盘路径浏览(复用 cloud Provider.List) |
### 5.2 书架与详情
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books` | 书架列表。参数:`library_id`、`keyword`、`media_kind`、`format`、`tag`、`sort`(`title/added_at/last_read/progress`)、`page/page_size` |
| GET | `/api/books/continue-reading` | 最近在读,首页「继续阅读」区块用 |
| GET | `/api/books/:id` | 详情(元信息 + 目录树 + 当前用户进度 + 收藏态) |
| GET | `/api/books/:id/cover` | 封面。走 `ImageProxy` 的缓存与缩放,参数 `w` |
| GET | `/api/books/:id/chapters/:index` | 章节正文。电子书返回 `text/html`(已清洗)或 `application/json` 结构化段落 |
| GET | `/api/books/:id/res/*path` | EPUB 内部资源(图片/字体/CSS),路径参数为 zip 内条目 |
| GET | `/api/books/:id/pages/:index` | 漫画单页图片,`Content-Type: image/*` + 长效缓存头 |
| GET | `/api/books/:id/file` | 原始文件流(Range),pdf.js 与「下载原文件」共用 |
| POST | `/api/books/:id/favorite` | 收藏 / 取消收藏 |
| DELETE | `/api/books/:id` | 删除(`can_manage_books`) |
**章节响应格式(推荐 JSON 而非裸 HTML)**:
```json
{
"index": 12,
"title": "第十二章 雨夜",
"char_count": 3820,
"blocks": [
{ "type": "p", "text": "……" },
{ "type": "img", "src": "/api/books/xxx/res/images/1.png" }
],
"next_index": 13,
"prev_index": 11
}
```
用结构化 blocks 而非 HTML 的理由:
1. 前端可安全渲染,不必 `dangerouslySetInnerHTML`,彻底绕开 XSS 面。
2. 分页模式需要按节点测量高度做分栏,结构化的段落数组比操作 DOM 简单得多。
3. 字号/行距/主题切换只需重渲染,不碰 HTML 字符串。
保底方案:`?format=html` 仍返回清洗后的 HTML,供 EPUB 中复杂排版(表格、脚注、双向文字)回退。
### 5.3 进度、批注、统计
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/books/:id/progress` | 当前用户进度 |
| PUT | `/api/books/:id/progress` | 保存进度。前端**节流 5s + 页面卸载时 `sendBeacon`** |
| GET | `/api/books/:id/annotations` | 批注列表 |
| POST | `/api/books/:id/annotations` | 新建 |
| PATCH | `/api/books/:id/annotations/:aid` | 修改 note / color |
| DELETE | `/api/books/:id/annotations/:aid` | 删除 |
| POST | `/api/books/:id/heartbeat` | 阅读心跳,body 为 `{seconds, chars, pages}`,落 `book_reading_sessions` 小时桶 |
| GET | `/api/books/reader-settings` | 读取当前用户的阅读器偏好(`model.User.ReaderSettings`) |
| PUT | `/api/books/reader-settings` | 保存阅读器偏好(服务端只做长度与枚举校验后原样存储) |
| GET | `/api/books/stats` | 个人统计:累计时长、已读书数、在读、近 30 天热力图 |
| GET | `/api/books/stats/overview` | 管理员视角:全站阅读统计(`can_access_settings`) |
进度写入的并发安全:`uniq_user_book` 复合唯一键 + 先 `Updates` 再 `Create` 的 upsert 模式。**参照 `internal/database/schema_migration.go` 里 `dedupePlaybackHistories` 的前车之鉴**——`PlaybackHistory` 曾因 read-then-write 产生重复行导致唯一索引进不去,新表直接写 upsert,不要复制那个 bug。
---
## 6. 前端设计
### 6.1 路由与导航
`web/src/appRoutes.tsx` 新增懒加载路由:
```tsx
const BookshelfPage = lazy(() => import('./pages/BookshelfPage').then(m => ({ default: m.BookshelfPage })))
const BookDetailPage = lazy(() => import('./pages/BookDetailPage').then(m => ({ default: m.BookDetailPage })))
const BookReaderPage = lazy(() => import('./pages/BookReaderPage').then(m => ({ default: m.BookReaderPage })))
const BookStatsPage = lazy(() => import('./pages/BookStatsPage').then(m => ({ default: m.BookStatsPage })))
const BookLibraryPage = lazy(() => import('./pages/BookLibraryPage').then(m => ({ default: m.BookLibraryPage })))
```
```
/books 书架
/books/:id 书籍详情(目录、元信息、开始阅读)
/books/:id/read 阅读器(全屏,隐藏底栏)
/books/stats 阅读统计
/books/library 书库管理(adminOnly)
```
`web/src/components/layoutNavigation.ts` 的改动:
- `MEDIA_NAV_ITEMS` 与 `MOBILE_BOTTOM_NAV_ITEMS` 加「阅读」项(`BookOpen` 图标),普通用户可见。
- 新增 `isBookReaderRoute(pathname)`,并在 `shouldShowMobileBottomNav` 中排除 `/books/:id/read`,与 `isPlayerRoute` 的处理一致。
- `resolveHeaderBack` 补 `/books/...` 的返回链。
- `LAYOUT_NAV_ITEMS` 加「书库管理」条目,`adminOnly: true`。
### 6.2 页面组成
```
web/src/pages/
BookshelfPage.tsx 书架:筛选栏 + 网格/列表双视图 + 继续阅读横滑
BookDetailPage.tsx 详情:封面、元信息、目录树、进度、开始/继续阅读
BookReaderPage.tsx 阅读器外壳:顶栏 + 内容区 + 底部工具条 + 设置抽屉
BookStatsPage.tsx 统计:热力图 + 概览卡片
BookLibraryPage.tsx 书库管理:书库 CRUD + 书源 CRUD + 扫描触发与进度
web/src/components/
book/ReaderCore.tsx 渲染内核分发(按 media_kind + format)
book/ScrollReader.tsx 滚动流式
book/PagedReader.tsx 分页翻页
book/ComicReader.tsx 漫画(单页/双页/右开本/预加载)
book/PdfReader.tsx PDF(pdf.js)
book/ReaderToolbar.tsx 顶栏 + 底栏(章节、进度、目录、批注、设置)
book/ReaderSettingsPanel.tsx 阅读设置
book/ChapterTocDrawer.tsx 目录抽屉
book/AnnotationList.tsx 书签笔记列表
book/ReaderProgressBar.tsx 进度条(可拖拽跳章)
web/src/api/books.ts 接口封装
```
对于 4 类内容,`ReaderCore` 的分发是第一层决策:
| `media_kind` | `format` | 内核 |
| --- | --- | --- |
| ebook | epub / txt | `ScrollReader` 或 `PagedReader`(按 `settings.mode`) |
| ebook | pdf | `PdfReader` |
| comic | cbz / cbr / folder | `ComicReader` |
| ebook | mobi / azw3 | 不提供阅读,仅详情页 |
### 6.3 渲染内核选型(关键决策)
**结论:自研内核,不引入 epub.js / foliate-js。**
对比:
| 方案 | 优点 | 缺点 |
| --- | --- | --- |
| `epub.js` | 成熟、CFI 精确定位、多列分页开箱即用 | 维护停滞;CFI 定位难以与自研进度模型(`charOffset` / `percent`)对齐;PDF/漫画仍需另做两个内核 |
| `foliate-js` | 一套 API 覆盖 EPUB/MOBI/CBZ/PDF,排版质量高 | 生态小、文档薄、非稳定 API,需要 vendored 一份并自行承担升级风险 |
| **自研(推荐)** | 进度模型完全可控、跨端一致;零新增重依赖;与既有 Tailwind 主题体系天然统一 | 需要自己实现分页测量、脏 HTML 清洗、资源重写 |
自研方案的可行性依据:分页的本质是「CSS multi-column 布局 + `transform: translateX` 平移」,foliate-js 也是这么做的,核心约 200 行;滚动模式的虚拟化可以直接复用已有的 `react-virtuoso`(已在 `web/package.json`,用于 `VirtualMediaGrid`)。
自研必须做好的三件事:
1. **HTML 清洗**:服务端为主(见 4.2),前端用 `dompurify` 兜底。这是新增的唯一运行时依赖。
2. **资源重写**:EPUB 内部图片/字体/CSS 的 `src`、`href`、`url()` 全部重写到 `/api/books/:id/res/`,否则相对路径会 404。
3. **分页测量与重排**:容器尺寸变化(窗口 resize、字号切换、横竖屏)后必须重新分页,并把「当前段落 + 段内比例」作为锚点恢复位置,不能让用户跳回章首。
### 6.4 双模式实现
**滚动模式(`ScrollReader`)**
- 章内虚拟化:单章文本通常 2k–10k 字,直接整章渲染即可;跨章用「当前章 + 前后各一章」的窗口,滚动到边界时无缝追加。
- 进度:`IntersectionObserver` 观测可视段落,映射为 `charOffset`;`scroll_ratio` 同时上报。
- 优势:移动端体验好,实现简单,长段落无分页误差。
**分页模式(`PagedReader`)**
- 章内:容器设为多列(`column-width: <contentWidth>`),`overflow: hidden`,通过 `translateX` 翻页;总页数由 `scrollWidth / containerWidth` 得出。
- 跨章:翻到本章末尾自动加载下一章首页;反向同理。章首/章尾需处理「残页合并」,避免出现半屏空白页。
- 输入:左右方向键、空格、点击左右热区、滑动手势(移动端)。`comicDirection`/`pageAnimation` 控制方向与动画。
- 进度:`chapter_index` + `page_index` 映射回 `charOffset`。
两种模式共享 `BookProgress`,切换模式时用「章 + 比率」换算,不丢位置。
### 6.5 状态与持久化
- 阅读器设置来自 `authStore` 的用户信息(`ReaderSettings` 反序列化),改动后 `PUT /api/books/reader-settings` 持久化 + 本地 `localStorage` 兜底(首屏渲染不等接口)。
- 进度本地先写 `localStorage`(key `mebook:book:<id>:pos`),再节流同步服务端;页面隐藏/卸载用 `navigator.sendBeacon` 保证不丢。
- 新增 `web/src/stores/readerSettings.ts`(zustand),与既有 `playProfile.ts` 组织方式一致。
---
## 7. 权限与多用户
`model.UserPermission` 新增 4 位(当前 18 个字段,加后 22 位):
| 权限位 | 默认 | 含义 |
| --- | --- | --- |
| `can_read_books` | `true` | 书架、阅读、进度、批注 |
| `can_manage_book_library` | `false` | 书库 / 书源 CRUD、触发扫描、网盘浏览 |
| `can_manage_books` | `false` | 编辑书籍元信息、删除书、手动重新解析 |
| `can_view_book_stats` | `false` | 查看全站阅读统计 |
同步改动清单(**漏一处就会出现「后端有权限、前端不显示开关」的静默 bug**):
1. `internal/model/permission.go` — 字段、`NewDefaultPermission()`、`PermissionMap()`,并更新文件头注释里的数量描述(注释目前写「19项」,实际 18 个字段,顺手修正)。
2. `web/src/types/auth.ts` — `PermissionFlags` 接口加 4 个字段。
3. `web/src/stores/permissions.ts` — 默认值对象、中文标签映射、权限分组数组。
4. `web/src/hooks/usePermission.ts` — 若其中有分组注释需同步。
5. `internal/handler/permissions.go` — 权限矩阵响应(若有枚举)。
6. `web/src/pages/AdminUsersForm.tsx` / 权限勾选 UI — 若按分组硬编码了列表。
书库可见性:
- 管理员在用户管理页勾选该用户可访问的书库,写入 `User.AllowedBookLibraryIDs`。
- 空值 = 不限制(与影视库语义一致)。
- 过滤集中在一个 `bookVisibility` 助手,与现有的 `internal/handler/visibility.go` 并列(该文件就是影视库可见性的集中判定点,并且会与 `PlayProfile.AllowedLibraryIDs` 求交集)。**阅读模块首版不接播放配置档**——`PlayProfile` 是影视播放器概念(音量、转码参数、PIN),与阅读无关;但判定入口要与它放在同一层,将来若要按配置档限制书库才不用重构。
- **服务端强制**:`GET /api/books/:id`、章节、页面、资源(`/res/*`)、封面、原始文件流,**所有**按 ID 取内容的接口都要校验 `book.LibraryID ∈ 用户可见书库`,不能只靠书架列表过滤。这是最容易漏的越权点:`/api/books/:id/res/*path` 会直接吐出书籍内部的原始资源,漏校验等于开放全库文件读取。
- 用户被取消书库授权后,其 `BookProgress` / `BookAnnotation` 保留不删(授权恢复即恢复),但接口一律按当前可见性判定,不因历史数据放行。
---
## 8. 阅读统计
- **采集**:阅读器每 30s 发一次 `heartbeat`,卸载时补发一次;服务端按 `(user_id, book_id, 小时桶)` 累加,行数上限 = 用户数 × 书数 × 阅读小时数,可控。
- **汇总**:`Scheduler` 每日 03:00 把昨天之前的 session 滚进 `BookDailyStat`(复用 `service.Scheduler` 的既有定时任务注册方式)。
- **展示**:
- 个人页「阅读统计」:累计时长、读完本数、在读本数、近 30 天热力图(仿 GitHub 贡献图)、阅读类型分布(电子书 / 漫画)。
- 首页新增「继续阅读」横滑区块(参照 `HomePageSections.tsx` 里既有区块的写法)。
- 管理员视图:全站活跃度、热门书籍 Top 20(需 `can_view_book_stats`)。
隐私:统计仅对本人与管理员可见;管理员视图只出聚合数据,不暴露单个用户的阅读内容。
---
## 9. 分期实施计划
### P0 — 端到端可用(本地书库 / EPUB + TXT 电子书)
目标:能扫库、能在网页上把一本书读完、关掉浏览器再打开能续读。
| # | 交付物 |
| --- | --- |
| 1 | `internal/model/book.go` 九张表 + `AllModels()` 注册 + 迁移验证(SQLite 与 PostgreSQL 各跑一次升级) |
| 2 | `BookLibrary` / `BookSource` / `Book` / `BookChapter` / `BookProgress` 的 repository |
| 3 | `BookParser`:EPUB 与 TXT 解析(含 GBK 编码探测、章节正则切分、封面提取) |
| 4 | `BookScannerService`:本地目录扫描 + upsert + 进度广播 |
| 5 | API:书库 CRUD、书源 CRUD、扫描、书架列表、详情、章节正文、封面、进度读写、阅读器偏好读写 |
| 6 | 前端:`BookshelfPage`、`BookDetailPage`、`BookReaderPage`(仅滚动模式)、目录抽屉、阅读设置面板 |
| 7 | 权限:4 个权限位 + `AllowedBookLibraryIDs` 全链路(含服务端越权校验) |
**验收标准**
- 一个含 50 本 EPUB 与 20 本 GBK 编码 TXT 的目录,扫描后书架正确列出,标题/作者/封面/章节目录无误。
- 任意一本书可连续阅读 3 章以上,刷新页面后回到原位置(误差 < 1 段)。
- 权限为 `can_read_books=false` 的账号访问 `/api/books` 返回 403;直接请求他人书库的 `/api/books/:id/chapters/0`、`/api/books/:id/res/*`、`/api/books/:id/file` 同样被拒。
- SQLite 单文件档与 PostgreSQL 档都能从旧版本升级启动,无迁移报错。
### P1 — 漫画 + 分页模式 + 网盘直链
| # | 交付物 |
| --- | --- |
| 1 | `ComicReader`:CBZ 解析、单页/双页、右开本、相邻页预加载 |
| 2 | `PagedReader`:分页测量、resize 重排、跨章衔接、键鼠与手势输入 |
| 3 | 网盘书源:`StorageKind=cloud` 的书源配置、`cloud.Provider` 接入、本地缓存目录 + LRU 淘汰 |
| 4 | 网盘书籍的索引拉取与阅读反代(含 `Content-Range` 透传) |
| 5 | PDF:`PdfReader`(pdf.js)+ Range 文件流接口 |
| 6 | 图片文件夹型漫画 |
**验收标准**
- CBZ 单册 300 页可流畅翻阅,双页模式断页处理正确(避免跨章错配)。
- 分页模式下切换字号、resize 窗口、手机横竖屏切换后,位置不跳、不出现空白页。
- 挂在 OpenList 与 115 上的 EPUB 能正常入库并在线阅读,缓存目录达到上限后按 LRU 淘汰且不影响已有进度。
- 20MB 以上 PDF 可跳页、可缩放。
### P2 — 批注、统计与体验打磨
| # | 交付物 |
| --- | --- |
| 1 | 划线 / 书签 / 笔记:`BookAnnotation` 接口与 UI,批注列表与跳转 |
| 2 | 阅读统计:心跳采集、每日汇总任务、个人统计页、首页「继续阅读」区块 |
| 3 | 管理员统计视图 + 热门书籍排行 |
| 4 | 书库定时扫描(`Scheduler` 接入,默认关闭) |
| 5 | 书架高级筛选与排序、合集(系列)聚合视图 |
| 6 | MOBI/AZW3 元信息解析(仍不做在线阅读,仅提供下载) |
| 7 | 部署文档与 compose 注释更新(书籍目录挂载说明) |
### P3 — 可选增强
听书 TTS、跨设备批注冲突合并、书源自动整理(仿 `OrganizerService`)、EPUB 阅读器内注释锚点高亮。
---
## 10. 风险与待拍板项
### 10.1 需要你拍板的两点
**① 网盘书籍的缓存策略**
- 选项 A(本方案):索引时完整下载到缓存目录,阅读时优先本地。省流量、体验好,但全新书首次打开有等待,且占用磁盘(默认 2GB 上限)。
- 选项 B:完全不落盘,每次按 Range/整文件从网盘拉。省磁盘,但每次打开都要重新下载,网盘限速时体验很差。
- 选项 C:折中——只对 EPUB/CBZ 缓存(解析必须读全文),漫画原图与 PDF 走流式。
我的建议是 **C**,因为它把「必须落盘」和「可以不落盘」分开了。
**② 章节正文的返回格式**
- JSON blocks(本方案推荐):安全、便于分页测量,但复杂 EPUB 排版(表格、脚注、竖排)会降级。
- 清洗后 HTML:保真度高,但前端要 `dangerouslySetInnerHTML`,XSS 面更大。
- 我的建议是 **JSON blocks 为主 + `?format=html` 回退**,两者都实现,前端在遇到 `type: "html-block"` 时回退渲染。
### 10.2 技术风险
| 风险 | 影响 | 缓解 |
| --- | --- | --- |
| 自研分页内核的边界情况多(残页、跨章、RTL、竖排) | P1 可能超期 | P0 先只做滚动模式;分页单独立项,配套 `playerPageModel.test.ts` 那样的单测 |
| TXT 章节正则对网文变体覆盖不足 | 目录错乱 | 提供「手动重新切分」入口,规则可配(仿 `RecognitionWordsPanel` 的可配置词表模式) |
| 网盘直链失效 / 限速 / 防盗链 | 阅读中断 | 复用现有 115 换链与 `url_cache.go` 的缓存机制;失败时前端降级为「下载原文件」 |
| 大 TXT(>50MB)章节表行数过多 | SQLite 写入慢 | 章节超过阈值(如 5000 章)时按固定窗口粗切,或改为「按需切分 + 缓存到章节表」的惰性策略 |
| 缓存目录膨胀 | 磁盘打满 | 容量上限 + 复用 `service.PruneImageCache` 的 LRU 清理 + 系统设置页可见 |
| 数据库迁移对老库不兼容 | 升级失败 | 新表全部是纯新增,无列变更;不触碰 `ensurePostgresColumnCompatibility` 的既有语句 |
### 10.3 不引入的新依赖清单
| 依赖 | 用途 | 取舍 |
| --- | --- | --- |
| `dompurify` | 前端 HTML 清洗兜底 | **建议引入**(前端必需) |
| `pdfjs-dist` | PDF 渲染 | **建议引入**(P1) |
| `github.com/nwaples/rardecode` | CBR 解压 | 可选;不接受则 CBR 首版只入库 |
| `epub.js` / `foliate-js` | EPUB 渲染 | **不引入**,见 6.3 |
---
## 11. 测试策略
与项目现有测试密度对齐(`internal/service` 下大量 `_test.go`,前端有 `*.test.ts`):
**后端**
- `book_parser_test.go`:EPUB / TXT 各准备 fixture(`testdata/` 下小体积样本),断言元信息、章节数、章节边界字节偏移、GBK 转码正确性。
- `book_scanner_test.go`:临时目录扫描 + 重复扫描幂等 + 源文件删除后软删。
- `book_progress_test.go`:并发 upsert 不产生重复行(直接复现 `dedupePlaybackHistories` 防的那类 bug)。
- `book_permission_test.go`:越权矩阵,逐接口断言非可见书库返回 403/404。
- Handler 层:仿 `internal/handler/media_test.go` 起的 `httptest` + 真实内存 SQLite。
**前端**
- `readerModel.test.ts`:模式切换时的位置换算(`scroll ↔ paged`、`charOffset ↔ pageIndex`)、百分比计算、跨章边界。
- 分页计算的纯函数抽出单测(不含 DOM),参照 `web/src/pages/playerPageModel.test.ts` 的做法——把逻辑从组件里拔出来测,是项目已有的好传统。
---
## 12. 附:改动文件清单
**后端新增**
```
internal/model/book.go
internal/repository/book_repository.go
internal/service/book_parser.go EPUB / TXT / CBZ 解析
internal/service/book_parser_epub.go
internal/service/book_parser_txt.go
internal/service/book_parser_comic.go
internal/service/book_scanner.go
internal/service/book_reader.go 章节 / 页面 / 资源的读取与清洗
internal/service/book_progress.go
internal/service/book_stats.go
internal/service/book_cache.go 网盘缓存与 LRU
internal/handler/books.go
internal/handler/books_library.go
internal/handler/books_reader.go
internal/handler/routes_books.go
```
**后端修改**
```
internal/model/model.go AllModels() 追加 9 张表
internal/model/permission.go 4 个权限位
internal/model/user.go ReaderSettings / AllowedBookLibraryIDs
internal/repository/repository.go Container 加字段
internal/service/service.go Container 加字段 + Boot() 启动扫描
internal/handler/routes_authenticated.go 挂载 registerAuthedBookRoutes
internal/service/scheduler_local_jobs.go 书籍缓存清理 + 每日阅读统计汇总
internal/config/types.go CacheConfig 加 BooksMaxSizeMB;新增 BookConfig(扫描并发等)
docker-compose*.yml 书籍目录挂载注释
README.md / README_EN.md 能力表新增「阅读」
```
**前端新增**
```
web/src/api/books.ts
web/src/stores/readerSettings.ts
web/src/pages/BookshelfPage.tsx
web/src/pages/BookDetailPage.tsx
web/src/pages/BookReaderPage.tsx
web/src/pages/BookStatsPage.tsx
web/src/pages/BookLibraryPage.tsx
web/src/components/book/*.tsx
```
**前端修改**
```
web/src/appRoutes.tsx 4 条路由
web/src/components/layoutNavigation.ts 导航项、阅读器路由判定、返回链
web/src/types/auth.ts 权限位
web/src/stores/permissions.ts 权限位默认值 / 标签 / 分组
web/src/pages/HomePageSections.tsx 「继续阅读」区块
web/src/pages/settingsGroupBooks.ts (新增)阅读设置分组
web/src/pages/settingsGroups.ts 把 settingsGroupBooks 加入 GROUPS 数组
```
@@ -0,0 +1,36 @@
# Segment Coverage Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** IntroDB prewarm + same-season intro propagation + shorter miss TTL + playback-only duration probe (option C).
**Architecture:** Extend `MediaSegmentService` for merge/propagate/prewarm; add scheduler job `segment_prewarm`; repository queries for candidates and season siblings.
**Tech Stack:** Go, GORM/SQLite, existing SchedulerService + IntroDB client.
---
### Task 1: Negative cache TTL + ledgerFresh tests
**Files:** `internal/service/media_segment.go`, `internal/service/media_segment_test.go`
- Change `segmentMissingTTL` to 24h; update tests for fresh/stale miss.
### Task 2: Playback merge + propagation
**Files:** `media_segment.go`, `media_segment_repository.go`, `media_repository.go`, tests
- Constant `PropagatedSource = "propagated"`.
- `ListForPlayback` merges sources with priority manual > theintrodb > propagated.
- After successful IntroDB refresh with intro, propagate to same-season siblings (shared `tm_db_id` or `series_id`).
### Task 3: Prewarm API + scheduler job
**Files:** `media_segment.go`, `media_segment_repository.go`, `scheduler.go`, `scheduler_segment_jobs.go`, `service_builder.go`, tests
- `Prewarm(ctx, limit)` rate-limited refreshes.
- Job `segment_prewarm` every 6h.
### Task 4: Verify
- `go test` for affected packages.
@@ -0,0 +1,46 @@
# Playback Correctness and Isolation Implementation Plan
**Goal:** Fix the five playback defects from the 2026-10-21 review and make playback capability queries side-effect-free, without replacing the player or adding dependencies.
**Scope:** The user approved implementing the reviewed fixes. Work in the current clean checkout; do not commit, push, deploy, or alter unrelated files.
## Design and invariants
- Keep existing media IDs, stream routes, direct playback and external-player URLs compatible.
- Add optional `session_id` to local HLS requests. Authenticated handlers supply the actual user identity; a client parameter never supplies the owner. New web playback sessions use random IDs. Legacy clients are isolated by user and, where available, Emby play-session/device identity.
- Keep transcoder media lookup/logging separate from its internal task key. A task key incorporates user, media and playback session. Each actual ffmpeg run writes to a fresh generation directory, so delayed segment requests never read a replacement run's output.
- Stop only the authenticated caller's playback session. Check media visibility and scoped playback tokens on stop, just as on playlist/segment requests. Segment and playlist queries must match the running generation.
- Cloud child requests resolve their real session media first, compare query media, enforce token scope and visibility against the real media, and reject mismatches before upstream fetch.
- Treat player business positions as absolute source seconds. Only local HLS converts to/from a relative ffmpeg timeline.
- Invalidate outstanding cloud-transcode requests on media/unmount, explicit source/quality changes, and newer requests. Guard both fulfilled and rejected responses.
- Select local fallbacks only from available local qualities. Keep the existing 115-first preference when a cloud quality is available.
- `PlaybackInfo` only reads capabilities/status. Explicit `StartTranscode` requests enqueue cloud work. Retain pending/ready response contracts, polling, and retries.
## Tasks
1. Add regression tests for local-scope isolation, stale generations, stop authorization, cloud media binding, pure capability reads, timeline conversion, available-local selection and stale async results. Run tests before implementation and verify the intended failures.
2. Add tested playback helpers; route skip/undo/progress/quality/subtitle calculations through the common absolute timeline; guard cloud transcode results and fix local fallback choice.
3. Extend existing transcoder/stream methods with an optional scope, preserve legacy internal calls, isolate task keys/output directories, and pass caller scope from web and Emby handlers. Bind web local HLS URLs and stop requests to captured session IDs.
4. Enforce real-media authorization on cloud child requests and authenticated ownership on local stop.
5. Split read-only cloud capability lookup from explicit cloud enqueue. Update existing cloud tests to use the explicit start operation and test no push during reads.
6. Run focused and full Go tests, frontend tests, TypeScript/Vite build, lint diagnostics and review the diff for security, lifecycle and API compatibility. Fix regressions before reporting completion.
## Verification commands
- `node --experimental-strip-types --test src/pages/hlsPlayback.test.ts src/pages/directPlaySeek.test.ts src/pages/playerPageModel.test.ts src/pages/playbackQuality.test.ts src/pages/playbackRequestGuard.test.ts src/utils/skipSegments.test.ts`
- `go test ./internal/service ./internal/handler ./internal/middleware -count=1`
- `go test ./...`
- `npm run build` and targeted ESLint for changed frontend files.
## Out of scope
New remux/audio-only ffmpeg pipelines, speculative browser codec policy changes, player replacement, broad UI refactors, performance improvement claims without measurements, and production deployment.
## Verification outcome
- Six frontend playback regression scripts pass; targeted ESLint and the TypeScript/Vite production build pass. The existing bundle-size warning remains.
- Playback/stream/transcode/progress tests in service, handler and middleware pass, including scoped output reads, stale generation rejection, scoped stops, device-to-segment propagation, read-only capability queries and concurrent cloud start deduplication.
- Whole-repository tests pass with only `TestQueueLibraryRootScanImportsNewLibraryMedia` and `TestSQLiteRestoreStagesWithoutChangingLiveDatabase` excluded. Both were run independently and reproduce Windows file-lock / SQLite errors in unmodified scan/database code. They are not silently disabled in source.
- FFmpeg is not installed in this environment, so the existing real-transcode lifecycle test skips. Actual video, upstream 115 and browser end-to-end playback remain unverified.
- Clients with no playback-session or device identifier retain per-user legacy isolation; new web requests always carry an independent session ID.
- No commit, push or deployment was performed.
@@ -0,0 +1,52 @@
# Segment Coverage Improvements (1–4, option C)
## Goal
Raise intro/outro skip availability without Chromaprint or full-library STRM probing.
## Scope (approved)
1. **IntroDB prewarm** — background job for all queryable media (TMDb + season/episode).
2. **Duration** — playback-path only (no full STRM scan); keep `ListForPlayback` → `EnsureAsync`.
3. **Same-season intro propagation** — copy intro to sibling episodes after a hit.
4. **Negative cache** — miss TTL 24h; 403/429/timeout must not write miss ledger.
## Non-goals
Chromaprint, manual segment UI, library-name filters, batch STRM duration scan.
## Behavior
### Prewarm (`segment_prewarm`)
- Scheduler job, interval 6h, initial delay 6h (avoid restart spike).
- **Opt-in via `segment.prewarm_enabled` (default off)** — system settings toggle「后台预热 IntroDB 片头片段」.
- Candidates: `tm_db_id > 0`, season/episode or movie, ledger missing or stale.
- Order: recently played first, then others.
- Rate: ~1 req/s, stop on context cancel; respect IntroDB 429 retry already in client.
- Reuse `MediaSegmentService` refresh + propagation.
- Manual `RunNow` can bypass the toggle (same pattern as organize).
### Propagation
- After TheIntroDB refresh finds at least one `intro` span, write the same intro window to same-season siblings that lack a `theintrodb` intro.
- Source tag: `propagated`.
- Do not propagate credits/preview/recap.
- Playback merge priority per kind: `manual` > `theintrodb` > `propagated`.
### Negative cache
- `segmentMissingTTL`: 24h (was 7d).
- `segmentFoundTTL`: 30d (unchanged).
- Provider errors (non-404) continue to skip ledger writes.
### Duration (C)
- No new batch probe job.
- Existing `defer ensureMediaProbe` on playback remains the only STRM duration path.
## Success criteria
- Prewarm increases `media_segment_fetches` over time without blocking play.
- One IntroDB intro hit can surface skip on sibling episodes via `propagated`.
- Misses are retried within ~24h (sooner if recently played and prewarm runs).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

+17 -5
View File
@@ -1,6 +1,6 @@
module github.com/truewhile/MeBox
go 1.25.0
go 1.26.0
require (
fyne.io/systray v1.12.2
@@ -19,9 +19,9 @@ require (
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/sync v0.22.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -30,6 +30,12 @@ require (
)
require (
github.com/PaesslerAG/gval v1.0.0 // indirect
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
github.com/PuerkitoBio/goquery v1.13.0 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/antchfx/htmlquery v1.3.6 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -38,6 +44,9 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -47,9 +56,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -90,8 +102,8 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.70.0 // indirect
+97
View File
@@ -1,7 +1,20 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -24,6 +37,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
@@ -54,6 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -62,7 +83,11 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -177,6 +202,7 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@@ -191,33 +217,104 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+7 -3
View File
@@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("database.postgres_trgm_index", true)
v.SetDefault("secrets.jwt_secret", "")
@@ -44,13 +45,16 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.images_max_size_mb", 500)
v.SetDefault("cache.images_max_size_mb", 2048)
v.SetDefault("cache.images_originals_max_size_mb", 256)
v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB)
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("cache.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.emby_latest_ttl_seconds", 900)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+12
View File
@@ -47,6 +47,9 @@ func (c *Config) normalize() error {
if c.Cache.ImagesMaxSizeMB < 0 {
c.Cache.ImagesMaxSizeMB = 0
}
if c.Cache.ImagesOriginalsMaxSizeMB < 0 {
c.Cache.ImagesOriginalsMaxSizeMB = 0
}
if c.Cache.MemoryMaxSizeMB <= 0 {
c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB
}
@@ -56,6 +59,15 @@ func (c *Config) normalize() error {
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90
}
if c.Cache.ReaderContentMaxSizeMB < 0 {
c.Cache.ReaderContentMaxSizeMB = 0
}
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
if c.Cache.ReaderFilesTTLHours < 0 {
c.Cache.ReaderFilesTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_media"
+29 -12
View File
@@ -50,7 +50,8 @@ type AppConfig struct {
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"`
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
}
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
@@ -115,22 +120,34 @@ type LoggingConfig struct {
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
CacheDir string `mapstructure:"cache_dir"`
// ImagesMaxSizeMB 是整个磁盘缓存的总容量上限(MB),不是 images 子目录
// 的配额。参与统计的池为 hls、images、library-covers;超限时按该顺序淘汰
// 最旧文件。reader-audio 不在其中:它按 reader.audioMediaMaxBytes 的固定
// 配额(当前 1GiB)在写入时独立淘汰,不参与这里的总量。
// 0 表示不限制。
ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"`
// ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是
// 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。
ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"`
// MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
// 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建,
// 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
// 0 表示使用内置兜底值(30 天)。
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
}
type SearchConfig struct {
+238
View File
@@ -0,0 +1,238 @@
package database
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// PostgreSQL tests use a disposable schema, never the caller's existing tables.
// Set MEBOX_TEST_POSTGRES_DSN to include a real PostgreSQL server in local runs.
func compatibilityDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var db *gorm.DB
if dialect == "sqlite" {
cfg := &config.Config{}
cfg.Database.Type = dialect
cfg.Database.DBPath = filepath.Join(t.TempDir(), "test.db")
cfg.Database.WALMode = true
cfg.Database.BusyTimeout = 5000
cfg.Database.MaxOpenConns = 8
var err error
db, err = Open(cfg, nil)
if err != nil {
t.Fatal(err)
}
} else {
dsn := os.Getenv("MEBOX_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set MEBOX_TEST_POSTGRES_DSN to run PostgreSQL integration tests")
}
pgcfg, err := pgx.ParseConfig(dsn)
if err != nil {
t.Fatal("invalid PostgreSQL test configuration")
}
admin := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = admin.Close() })
schemaName := "mebox_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec("CREATE SCHEMA " + quoteIdent(schemaName)); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := admin.Exec("DROP SCHEMA " + quoteIdent(schemaName) + " CASCADE"); err != nil {
t.Error(err)
}
})
pgcfg.RuntimeParams["search_path"] = schemaName + ",public"
conn := stdlib.OpenDB(*pgcfg)
t.Cleanup(func() { _ = conn.Close() })
db, err = gorm.Open(postgres.New(postgres.Config{Conn: conn}), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
}
db.Logger = logger.Default.LogMode(logger.Silent)
if conn, err := db.DB(); err == nil {
t.Cleanup(func() { _ = conn.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestDatabaseCompatibility(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
t.Run("long_paths_and_restart", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Distinct CJK characters prevent TOAST compression from hiding
// the PostgreSQL btree entry size limit (> 2704 bytes).
var path strings.Builder
path.WriteString("/media/")
for i := range 980 {
path.WriteRune(rune(0x4e00 + i))
}
path.WriteString(".mkv")
media := model.Media{Path: path.String(), Title: "Long path"}
if err := db.Create(&media).Error; err != nil {
t.Fatalf("insert long media path: %v", err)
}
if err := db.Create(&model.Media{Path: media.Path}).Error; err == nil {
t.Fatal("duplicate path accepted")
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("migrate existing database: %v", err)
}
var found model.Media
if err := db.Where("path = ?", media.Path).First(&found).Error; err != nil {
t.Fatalf("lookup long path: %v", err)
}
})
if dialect == "postgres" {
t.Run("upgrade_legacy_path_indexes", func(t *testing.T) {
db := compatibilityDB(t, dialect)
for _, unique := range []string{"UNIQUE ", ""} {
if err := db.Exec("DROP INDEX idx_media_path").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE " + unique + "INDEX idx_media_path ON media(path)").Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := ensureMediaPathIndex(db); err != nil {
t.Fatal(err)
}
}
isUnique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil || !found || isUnique || method != "hash" {
t.Fatalf("legacy index not upgraded: unique=%v method=%s found=%v err=%v", isUnique, method, found, err)
}
}
})
}
t.Run("search_ranking", func(t *testing.T) {
db := compatibilityDB(t, dialect)
// Exercise LIKE fallback in both engines.
if dialect == "sqlite" {
resetMediaSearchIndex(db)
}
for i, title := range []string{"The Alpha story", "Alpha sequel", "Alpha"} {
row := model.Media{Base: model.Base{ID: fmt.Sprintf("m-%d", i), CreatedAt: time.Now().Add(time.Duration(i) * time.Minute)}, Title: title, Path: fmt.Sprintf("/media/%d.mkv", i)}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "Alpha", 0, 10, repository.MediaQueryFilter{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 3 || len(items) != 3 || items[0].Title != "Alpha" || items[1].Title != "Alpha sequel" {
t.Fatalf("unexpected search ranking: total=%d, items=%v", total, items)
}
})
t.Run("concurrent_media_upsert", func(t *testing.T) {
db := compatibilityDB(t, dialect)
repo := repository.New(db).Media
const workers = 8
// Synchronize the initial lookups to force the insert race.
var lookups sync.WaitGroup
lookups.Add(workers)
if dialect == "postgres" {
if err := db.Callback().Query().After("gorm:after_query").Register("test:insert_race", func(tx *gorm.DB) {
if tx.Statement.Table == "media" && tx.Error == gorm.ErrRecordNotFound {
lookups.Done()
lookups.Wait()
}
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
errs := make(chan error, workers)
for range workers {
go func() { errs <- repo.Upsert(ctx, &model.Media{Path: "/media/shared.mkv", Title: "Shared"}) }()
}
for range workers {
if err := <-errs; err != nil {
t.Errorf("concurrent upsert: %v", err)
}
}
var count int64
if err := db.Model(&model.Media{}).Count(&count).Error; err != nil || count != 1 {
t.Fatalf("media count=%d, err=%v", count, err)
}
})
t.Run("search_filters", func(t *testing.T) {
db := compatibilityDB(t, dialect)
rows := []model.Media{
{Base: model.Base{ID: "keep"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-library"}, LibraryID: "lib-2", Genres: "Action", Year: 2020, Rating: 8},
{Base: model.Base{ID: "other-genre"}, LibraryID: "lib-1", Genres: "Comedy", Year: 2020, Rating: 8},
{Base: model.Base{ID: "too-old"}, LibraryID: "lib-1", Genres: "Action", Year: 1990, Rating: 8},
{Base: model.Base{ID: "too-new"}, LibraryID: "lib-1", Genres: "Action", Year: 2026, Rating: 8},
{Base: model.Base{ID: "low-rating"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 2},
{Base: model.Base{ID: "watched"}, LibraryID: "lib-1", Genres: "Action", Year: 2020, Rating: 8},
}
for i := range rows {
rows[i].Title = "Alpha " + rows[i].ID
rows[i].Path = "/media/" + rows[i].ID + ".mkv"
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&model.PlaybackHistory{UserID: "viewer", MediaID: "watched", Completed: true}).Error; err != nil {
t.Fatal(err)
}
items, total, err := repository.New(db).Media.SearchFilteredPage(t.Context(), "alpha", 0, 10, repository.MediaQueryFilter{
IncludeNSFW: true, LibraryID: "lib-1", Genres: []string{"Action"}, YearMin: 2000, YearMax: 2025, RatingMin: 5,
UnwatchedOnly: true, UnwatchedUserID: "viewer",
})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 || items[0].ID != "keep" {
t.Fatalf("search ignored filters: total=%d, items=%v", total, items)
}
})
t.Run("migration_preserves_zero_values", func(t *testing.T) {
src := compatibilityDB(t, "sqlite")
dst := compatibilityDB(t, dialect)
user := model.User{Username: "disabled", PasswordHash: "hash"}
if err := src.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := src.Model(&user).Updates(map[string]any{"is_active": false, "hide_adult": false, "player_volume": 0, "danmaku_enabled": false}).Error; err != nil {
t.Fatal(err)
}
if _, _, err := copyModelTables(src, dst, 500); err != nil {
t.Fatal(err)
}
var got model.User
if err := dst.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatal(err)
}
if got.IsActive || got.HideAdult || got.PlayerVolume != 0 || got.DanmakuEnabled {
t.Fatalf("migration replaced stored zero values: active=%v hide_adult=%v volume=%v danmaku=%v", got.IsActive, got.HideAdult, got.PlayerVolume, got.DanmakuEnabled)
}
})
})
}
}
+1 -1
View File
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
+50
View File
@@ -0,0 +1,50 @@
package database
import (
"fmt"
"reflect"
"gorm.io/gorm"
)
// InsertPreservingValues copies persisted model rows without applying creation
// hooks or defaults. Struct CreateInBatches treats false, 0 and empty strings as
// missing values, which would reactivate disabled accounts during migration or
// restore. Column maps preserve the exact values already stored in the source.
func InsertPreservingValues(db *gorm.DB, rows any, batchSize int) error {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(rows); err != nil {
return err
}
slice := reflect.Indirect(reflect.ValueOf(rows))
if slice.Kind() != reflect.Slice {
return fmt.Errorf("persisted rows must be a slice, got %T", rows)
}
if len(stmt.Schema.DBNames) == 0 {
return fmt.Errorf("persisted rows have no database columns")
}
values := make([]map[string]any, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
row := make(map[string]any, len(stmt.Schema.DBNames))
for _, name := range stmt.Schema.DBNames {
field := stmt.Schema.FieldsByDBName[name]
if field.Creatable {
row[name], _ = field.ValueOf(db.Statement.Context, slice.Index(i))
}
}
values = append(values, row)
}
if len(values) == 0 {
return nil
}
// Bound batches by each engine's bind-parameter limit.
parameterLimit := 65535
if isSQLite(db) {
parameterLimit = 32766
}
maxBatch := parameterLimit / len(stmt.Schema.DBNames)
if batchSize <= 0 || batchSize > maxBatch {
batchSize = maxBatch
}
return db.Table(stmt.Schema.Table).CreateInBatches(values, batchSize).Error
}
@@ -0,0 +1,141 @@
package database
import (
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
type legacyReaderSource struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
RawJSON string
Enabled bool
EnabledExplore bool
Variables string
Header string
}
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
func TestReaderSourceMigrationBackfill(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
t.Fatal(err)
}
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
if err := db.Create(shared).Error; err != nil {
t.Fatal(err)
}
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
if err := db.Create(softDeleted).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
t.Fatal(err)
}
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
books := []model.ReaderBook{
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
}
for i := range books {
if err := db.Create(&books[i]).Error; err != nil {
t.Fatal(err)
}
}
// 迁移。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
t.Fatalf("删旧索引失败: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatalf("回填失败: %v", err)
}
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
var rows []model.ReaderBookSource
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
byUser := map[string]model.ReaderBookSource{}
for _, r := range rows {
if r.SourceURL == "https://shared.example.com" {
byUser[r.UserID] = r
}
}
if len(byUser) != 2 {
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
}
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
}
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
t.Fatal("副本应保留原书源内容")
}
// 引用最多的用户保留原行(u1 有两本书)。
if byUser["u1"].ID != "legacy-1" {
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
}
// 2. 软删的历史行被清理。
var softLeft int64
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
t.Fatal(err)
}
if softLeft != 0 {
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
}
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
var orphans int64
if err := db.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
t.Fatal(err)
}
if orphans != 0 {
t.Fatal("无人引用的书源不应被分配归属")
}
// 4. 回填是幂等的(再次执行不产生重复副本)。
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatal(err)
}
var total int64
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
}
}
+293 -9
View File
@@ -1,27 +1,52 @@
package database
import (
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
return AutoMigrateWithConfig(db, nil, nil)
}
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil {
return err
}
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
@@ -34,6 +59,113 @@ func AutoMigrate(db *gorm.DB) error {
}
return ensureSQLiteQueryOptimizer(db)
}
return ensurePostgresSearchIndexes(db, cfg, log)
}
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
//
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
// 对整列建唯一索引会让这类行插入时报
//
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时用 hash 索引 idx_media_path 加速 path = ? 等值查找;非唯一 btree
// 也有索引项长度限制,不能继续对完整 path 建 btree。SQLite 沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
}
if !isPostgres(db) {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
return err
}
unique, method, found, err := postgresIndexProperties(db, "idx_media_path")
if err != nil {
return err
}
if found && (unique || method != "hash") {
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
return err
}
}
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media USING hash(path)`).Error
}
// postgresIndexProperties 返回当前 schema 下索引的唯一性与访问方法。
func postgresIndexProperties(db *gorm.DB, indexName string) (unique bool, method string, found bool, err error) {
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
var row struct {
IsUnique *bool
Method string
}
if err := db.Raw(`
SELECT i.indisunique AS is_unique, a.amname AS method
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_am a ON a.oid = c.relam
WHERE n.nspname = current_schema() AND c.relname = ?
`, indexName).Scan(&row).Error; err != nil {
return false, "", false, err
}
if row.IsUnique == nil {
return false, "", false, nil
}
return *row.IsUnique, row.Method, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
//
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
// 把搜索从整表扫描降到索引扫描。
//
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
//
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
if !isPostgres(db) {
return nil
}
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
if log != nil {
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
}
return nil
}
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
if log != nil {
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
}
return nil
}
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
// IF NOT EXISTS 直接跳过。
if log != nil {
log.Info("ensuring postgres trigram search indexes")
}
for _, stmt := range []string{
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
} {
if err := db.Exec(stmt).Error; err != nil {
if log != nil {
log.Warn("create trigram index failed; media search may fall back to sequential scan",
zap.String("statement", stmt), zap.Error(err))
}
return nil
}
}
return nil
}
@@ -42,6 +174,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per
@@ -67,16 +298,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
statements := []string{
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
// 只在类型确实不匹配时才 ALTER。
//
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
changes := []struct {
table string
column string
target string
}{
{"media", "container", "varchar(128)"},
{"media", "genres", "text"},
{"media", "series_id", "varchar(128)"},
{"media", "duplicate_of", "varchar(128)"},
{"playback_histories", "media_id", "varchar(128)"},
{"favorites", "media_id", "varchar(128)"},
{"playlist_items", "media_id", "varchar(128)"},
}
for _, stmt := range statements {
for _, change := range changes {
current, found, err := postgresColumnType(db, change.table, change.column)
if err != nil {
return err
}
if !found || current == change.target {
continue
}
stmt := "ALTER TABLE " + quoteIdent(change.table) +
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
if err := db.Exec(stmt).Error; err != nil {
return err
}
@@ -84,6 +334,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
return nil
}
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
// "varchar(128)" / "text"。列不存在时 found 为 false。
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
var row struct {
DataType string
MaxLength *int64
}
if err := db.Raw(`
SELECT data_type AS data_type, character_maximum_length AS max_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`, table, column).Scan(&row).Error; err != nil {
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
}
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
if dataType == "" {
return "", false, nil
}
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
switch dataType {
case "character varying", "varchar":
dataType = "varchar"
}
if row.MaxLength != nil && *row.MaxLength > 0 {
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
}
return dataType, true, nil
}
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
@@ -97,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error {
`DROP INDEX IF EXISTS idx_media_library_release_active`,
`CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`,
// 虚拟剧集分组索引:series_id 为空的剧集行要按
// (season_num, episode_num, episode_fraction, created_at) 排序后做内存
// 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的
// 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。
`CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`,
`CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`,
@@ -0,0 +1,54 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
//
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
var definition string
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
t.Fatal(err)
}
if definition == "" {
t.Fatal("AutoMigrate did not create idx_media_path")
}
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
t.Fatalf("idx_media_path is not unique: %q", definition)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ func copyModelTables(src, target *gorm.DB, batchSize int) (map[string]int64, int
if filtered.Len() > 0 {
filteredPtr := reflect.New(filtered.Type())
filteredPtr.Elem().Set(filtered)
if err := target.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(filteredPtr.Interface(), batchSize).Error; err != nil {
if err := InsertPreservingValues(target.Clauses(clause.OnConflict{DoNothing: true}), filteredPtr.Interface(), batchSize); err != nil {
return tableCounts, totalCopied, fmt.Errorf("copy sqlite table %s: %w", table, err)
}
copiedForTable += int64(filtered.Len())
+189
View File
@@ -0,0 +1,189 @@
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
)
var sqliteRestoreMu sync.Mutex
func sqliteRestorePath(cfg *config.Config) (string, error) {
if cfg == nil || strings.TrimSpace(cfg.Database.DBPath) == "" || cfg.Database.DBPath == ":memory:" || strings.HasPrefix(cfg.Database.DBPath, "file:") {
return "", errors.New("恢复需要配置有效的 SQLite 数据库文件路径")
}
return filepath.Abs(cfg.Database.DBPath)
}
func sqliteRestoreDSN(path, mode string) string {
u := url.URL{Scheme: "file", Path: filepath.ToSlash(path)}
q := url.Values{"mode": {mode}, "_pragma": {"busy_timeout(5000)"}}
u.RawQuery = q.Encode()
return u.String()
}
func validateSQLiteRestore(ctx context.Context, path string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return errors.New("备份必须是常规文件")
}
db, err := sql.Open("sqlite", sqliteRestoreDSN(path, "ro"))
if err != nil {
return err
}
defer db.Close()
var result string
if err := db.QueryRowContext(ctx, "PRAGMA quick_check").Scan(&result); err != nil {
return fmt.Errorf("SQLite 备份校验失败: %w", err)
}
if result != "ok" {
return fmt.Errorf("SQLite 备份校验失败: %s", result)
}
var tables int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('users', 'media', 'settings')").Scan(&tables); err != nil {
return err
}
if tables == 0 {
return errors.New("备份不包含 MeBox 数据表")
}
return nil
}
// StageSQLiteRestore leaves the live database and its WAL untouched. The
// validated copy is applied on the next startup, before any database is opened.
func StageSQLiteRestore(ctx context.Context, cfg *config.Config, source string) error {
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); err == nil {
return errors.New("已有等待恢复的备份,请重启 MeBox 后再操作")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := validateSQLiteRestore(ctx, source); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
defer in.Close()
out, err := os.CreateTemp(filepath.Dir(dbPath), ".mebox-restore-*")
if err != nil {
return err
}
tmp := out.Name()
defer os.Remove(tmp)
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Sync(); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
if err := validateSQLiteRestore(ctx, tmp); err != nil {
return err
}
return os.Rename(tmp, pending)
}
// ApplyPendingSQLiteRestore must run before database.Open and before workers or
// HTTP listeners start. The previous database is retained alongside the file.
func ApplyPendingSQLiteRestore(ctx context.Context, cfg *config.Config, log *zap.Logger) error {
if cfg == nil {
return nil
}
dialect := normalizeDatabaseType(cfg.Database.Type)
if dialect == "auto" {
dialect = effectiveAutoDatabaseType(cfg)
}
if dialect != "sqlite" {
return nil
}
sqliteRestoreMu.Lock()
defer sqliteRestoreMu.Unlock()
dbPath, err := sqliteRestorePath(cfg)
if err != nil {
return err
}
pending := dbPath + ".restore-pending"
if _, err := os.Lstat(pending); errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
if err := validateSQLiteRestore(ctx, pending); err != nil {
return err
}
previous := ""
if _, err := os.Lstat(dbPath); err == nil {
// A clean checkpoint prevents an old WAL from being replayed into the
// replacement database. A busy checkpoint leaves all files untouched.
live, err := sql.Open("sqlite", sqliteRestoreDSN(dbPath, "rw"))
if err != nil {
return err
}
var busy, pages, copied int
err = live.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &pages, &copied)
closeErr := live.Close()
if err != nil {
return err
}
if closeErr != nil {
return closeErr
}
if busy != 0 {
return errors.New("SQLite 数据库仍被占用,恢复已保留等待下次启动")
}
old, err := os.CreateTemp(filepath.Dir(dbPath), filepath.Base(dbPath)+".before-restore-*")
if err != nil {
return err
}
previous = old.Name()
if err := old.Close(); err != nil {
return err
}
if err := os.Remove(previous); err != nil {
return err
}
if err := os.Rename(dbPath, previous); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(pending, dbPath); err != nil {
if previous != "" {
if rollbackErr := os.Rename(previous, dbPath); rollbackErr != nil {
return fmt.Errorf("恢复失败: %w;回退失败: %v,原数据库保留于 %s", err, rollbackErr, previous)
}
}
return err
}
if log != nil {
log.Warn("SQLite backup restored before startup", zap.String("previous_database", previous))
}
return nil
}
+104
View File
@@ -0,0 +1,104 @@
package database
import (
"database/sql"
"os"
"path/filepath"
"testing"
"github.com/truewhile/MeBox/internal/config"
)
func TestSQLiteRestoreStagesWithoutChangingLiveDatabase(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.Type = "sqlite"
cfg.Database.DBPath = filepath.Join(root, "live.db")
live, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "rwc"))
if err != nil {
t.Fatal(err)
}
defer live.Close()
for _, statement := range []string{
"PRAGMA journal_mode=WAL", "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
"INSERT INTO settings VALUES ('state', 'backup')",
} {
if _, err := live.Exec(statement); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(root, "backup.db")
if _, err := live.Exec("VACUUM INTO ?", source); err != nil {
t.Fatal(err)
}
if _, err := live.Exec("UPDATE settings SET value = 'live'"); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err != nil {
t.Fatal(err)
}
var value string
if err := live.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "live" {
t.Fatalf("staging changed live database: value=%q err=%v", value, err)
}
if _, err := live.Exec("UPDATE settings SET value = 'after-stage'"); err != nil {
t.Fatalf("database could not write after staging: %v", err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("second staging should not replace the pending restore")
}
if err := os.Remove(source); err != nil {
t.Fatal(err)
}
if err := live.Close(); err != nil {
t.Fatal(err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatal(err)
}
restored, err := sql.Open("sqlite", sqliteRestoreDSN(cfg.Database.DBPath, "ro"))
if err != nil {
t.Fatal(err)
}
defer restored.Close()
if err := restored.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "backup" {
t.Fatalf("restore failed: value=%q err=%v", value, err)
}
previous, err := filepath.Glob(cfg.Database.DBPath + ".before-restore-*")
if err != nil || len(previous) != 1 {
t.Fatalf("previous database not preserved: files=%v err=%v", previous, err)
}
old, err := sql.Open("sqlite", sqliteRestoreDSN(previous[0], "ro"))
if err != nil {
t.Fatal(err)
}
defer old.Close()
if err := old.QueryRow("SELECT value FROM settings").Scan(&value); err != nil || value != "after-stage" {
t.Fatalf("old database lost latest writes: value=%q err=%v", value, err)
}
if err := ApplyPendingSQLiteRestore(t.Context(), cfg, nil); err != nil {
t.Fatalf("repeat startup: %v", err)
}
}
func TestSQLiteRestoreRejectsCorruptAndSymlinkBackups(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{}
cfg.Database.DBPath = filepath.Join(root, "live.db")
source := filepath.Join(root, "corrupt.db")
if err := os.WriteFile(source, []byte("not a SQLite database"), 0o600); err != nil {
t.Fatal(err)
}
if err := StageSQLiteRestore(t.Context(), cfg, source); err == nil {
t.Fatal("corrupt backup accepted")
}
link := filepath.Join(root, "link.db")
if err := os.Symlink(source, link); err == nil {
if err := StageSQLiteRestore(t.Context(), cfg, link); err == nil {
t.Fatal("symlink backup accepted")
}
}
if _, err := os.Stat(cfg.Database.DBPath + ".restore-pending"); !os.IsNotExist(err) {
t.Fatalf("failed validation left pending data: %v", err)
}
}
+15 -15
View File
@@ -104,19 +104,18 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
if ctx == nil {
ctx = context.Background()
}
// ctx 取消时唤醒等待者(cond 无法感知 ctx,用旁路 goroutine 广播)。
if done := ctx.Done(); done != nil {
stop := make(chan struct{})
defer close(stop)
go func() {
select {
case <-done:
g.cond.Broadcast()
case <-stop:
}
}()
// 广播也必须持有同一把锁,否则取消可能落在检查与 Wait 之间而丢失。
wake := func() {
g.mu.Lock()
defer g.mu.Unlock()
g.cond.Broadcast()
}
stopCancel := context.AfterFunc(ctx, wake)
defer stopCancel()
for {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if g.owner == nil {
holder := &sqliteGateHolder{
id: sqliteGateHolderSeq.Add(1),
@@ -125,16 +124,17 @@ func (g *sqliteWriteGate) Lock(ctx context.Context) (*sqliteGateHolder, error) {
g.owner = holder
return holder, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if time.Since(g.owner.acquired) > sqliteGateHoldLimit {
remaining := sqliteGateHoldLimit - time.Since(g.owner.acquired)
if remaining <= 0 {
// 持有者疑似 panic 泄漏(After 回调未执行):强制回收。
g.owner = nil
g.cond.Broadcast()
continue
}
// 无后续写入或取消时,也要在租约到期后主动唤醒等待者。
timer := time.AfterFunc(remaining, wake)
g.cond.Wait()
timer.Stop()
}
}
+79
View File
@@ -0,0 +1,79 @@
package database
import (
"context"
"errors"
"testing"
"time"
)
func TestSQLiteWriteGateRejectsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
gate := newSQLiteWriteGate()
holder, err := gate.Lock(ctx)
defer gate.Unlock(holder)
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
}
func TestSQLiteWriteGateWakesForExpiredHolder(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
// Make the lease expire shortly, without waiting a full minute.
gate.mu.Lock()
holder.acquired = time.Now().Add(-sqliteGateHoldLimit + 20*time.Millisecond)
gate.mu.Unlock()
done := make(chan *sqliteGateHolder, 1)
go func() {
next, _ := gate.Lock(t.Context())
done <- next
}()
select {
case next := <-done:
gate.Unlock(holder) // A stale holder must not unlock its successor.
gate.mu.Lock()
owner := gate.owner
gate.mu.Unlock()
gate.Unlock(next)
if next == nil || owner != next {
t.Fatal("expired holder released the new owner's lock")
}
case <-time.After(time.Second):
gate.Unlock(holder)
gate.Unlock(<-done)
t.Fatal("waiter was not woken when the holder expired")
}
}
func TestSQLiteWriteGateWakesForCancellation(t *testing.T) {
gate := newSQLiteWriteGate()
holder, err := gate.Lock(t.Context())
if err != nil {
t.Fatal(err)
}
defer gate.Unlock(holder)
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
next, err := gate.Lock(ctx)
gate.Unlock(next)
done <- err
}()
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("Lock error=%v, want context.Canceled", err)
}
case <-time.After(time.Second):
gate.Unlock(holder)
<-done
t.Fatal("waiter was not woken for cancellation")
}
}
+26 -2
View File
@@ -20,6 +20,12 @@ type settingReq struct {
Value string `json:"value"`
}
// maskedSettingKeys 里的设置值绝不能被完整下发:它们是可用于对外操作的凭据。
// 下发脱敏值,保存时再靠 isMaskedSettingValue 还原为「保持原值」。
var maskedSettingKeys = map[string]bool{
service.SettingTelegramBotToken: true,
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
@@ -27,10 +33,21 @@ func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range settings {
if maskedSettingKeys[settings[i].Key] {
settings[i].Value = service.MaskSecret(settings[i].Value)
}
}
c.JSON(http.StatusOK, settings)
}
}
// isMaskedSettingValue 识别「前端把脱敏值原样提交回来」的情况。此时必须保留
// 已存的真实值,否则一次保存就会把凭据覆盖成 ***。
func isMaskedSettingValue(value string) bool {
return strings.Contains(value, "***")
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
@@ -38,6 +55,11 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 脱敏值回传 == 用户没改这个凭据,保留库里已存的真实值。
if maskedSettingKeys[req.Key] && isMaskedSettingValue(req.Value) {
c.Status(http.StatusNoContent)
return
}
oldValue := ""
if req.Key == service.AdultLibraryIDsSettingKey {
oldValue, _ = svc.Repo.Setting.Get(c.Request.Context(), req.Key)
@@ -65,8 +87,10 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" {
svc.Transcoder.StopAll()
}
if req.Key == "cache.images_max_size_mb" && svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup")
if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" {
if svc.Scheduler != nil {
_ = svc.Scheduler.RunNowAsync(c.Request.Context(), "cache_cleanup")
}
}
if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil {
svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB)
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func backgroundTasksHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Tasks == nil {
c.JSON(http.StatusOK, service.TaskSnapshot{Active: []service.BackgroundTask{}, Recent: []service.BackgroundTask{}})
return
}
c.JSON(http.StatusOK, svc.Tasks.Snapshot())
}
}
+18 -1
View File
@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"path/filepath"
"github.com/gin-gonic/gin"
@@ -41,12 +42,28 @@ func deleteBackupHandler(svc *service.Container) gin.HandlerFunc {
}
}
func downloadBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
filename := c.Query("filename")
path, err := svc.Backup.ResolvePath(filename)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.FileAttachment(path, filepath.Base(path))
}
}
func restoreBackupHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Backup.Restore(c.Request.Context(), c.Query("filename")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored — restart the server to apply"})
message := "数据库已恢复,请重启 MeBox 以刷新运行时配置"
if svc.Repo.DB.Dialector.Name() == "sqlite" {
message = "备份已校验并暂存,请重启 MeBox 后应用恢复"
}
c.JSON(http.StatusOK, gin.H{"message": message, "restart_required": true})
}
}
@@ -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())
}
}
+37 -4
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
@@ -123,10 +124,32 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if !enforceScopedPlaybackToken(c, mediaID) {
if c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
err := svc.Cloud115.HLSProxy().ServeChild(
actualMediaID, err := svc.Cloud115.HLSProxy().SessionMediaID(c.Param("session"), c.Param("key"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
return
}
if mediaID != actualMediaID {
c.JSON(http.StatusForbidden, gin.H{"error": "playback session media mismatch"})
return
}
if !enforceScopedPlaybackToken(c, actualMediaID) {
return
}
if svc.Repo == nil || svc.Repo.Media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
m, findErr := svc.Repo.Media.FindByID(c.Request.Context(), actualMediaID)
if findErr != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
err = svc.Cloud115.HLSProxy().ServeChild(
c.Request.Context(),
c.Writer,
c.Request,
@@ -136,11 +159,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()})
}
+163
View File
@@ -0,0 +1,163 @@
// 云下载(115 离线下载)HTTP 接口。
//
// 全部挂在 /api/admin 下(管理员专用):首期不开放普通用户,否则单个用户
// 可以把账号的 115 云下载配额打空(见 docs/cloud-download-plan.md 第 9 节)。
package handler
import (
"io"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// cloudDownloadUploadHardLimit 是 .torrent 上传的硬上限(字节)。
// 真实上限由设置项 cloud_download.torrent_max_mb 控制,这里只防内存打爆。
const cloudDownloadUploadHardLimit = 256 << 20
func registerAdminCloudDownloadRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc))
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
}
func listCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("page"))
pageSize, _ := strconv.Atoi(c.Query("page_size"))
res, err := svc.CloudDownload.List(c.Request.Context(), repository.CloudDownloadQuery{
Status: c.Query("status"),
SyncPathID: c.Query("sync_path_id"),
AccountID: c.Query("account_id"),
Page: page,
PageSize: pageSize,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func createCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req service.CloudDownloadSubmit
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 逐条结果里已经包含失败原因,因此部分失败仍返回 200,前端按 items 展示。
res, err := svc.CloudDownload.Submit(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func uploadCloudDownloadTorrentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, cloudDownloadUploadHardLimit)
fileHeader, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少种子文件(form 字段名 file):" + err.Error()})
return
}
f, err := fileHeader.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "打开种子文件失败:" + err.Error()})
return
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取种子文件失败:" + err.Error()})
return
}
res, err := svc.CloudDownload.SubmitTorrent(c.Request.Context(), service.CloudDownloadTorrentRequest{
AccountID: c.PostForm("account_id"),
SyncPathID: c.PostForm("sync_path_id"),
FileName: fileHeader.Filename,
Data: data,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func cloudDownloadQuotaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
quota, err := svc.CloudDownload.Quota(c.Request.Context(), c.Query("account_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, quota)
}
}
func retryCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Retry(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func retryCloudDownloadPipelineHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.RetryPipeline(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func cancelCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Cancel(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func deleteCloudDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.CloudDownload.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
func clearFinishedCloudDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.CloudDownload.ClearFinished(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": n})
}
}
+26 -16
View File
@@ -112,8 +112,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
PlaybackRate float64 `json:"playback_rate"`
MergeSources bool `json:"merge_sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
@@ -121,9 +120,6 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if cfg.PlaybackRate != 1 {
t.Fatalf("default playback rate = %v, want 1", cfg.PlaybackRate)
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
@@ -141,7 +137,7 @@ func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
svc := newDanmakuSettingsService(t)
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"playback_rate\":1.5,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
body := "{\"enabled\":false,\"opacity\":0.6,\"font_size\":32,\"area\":0.7,\"merge_sources\":true,\"volume\":0.35,\"source\":\"https://dm.example/base/\",\"app_id\":\"my-app-id\",\"app_key\":\"my-app-secret\"}"
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", body, "user-1")
updateDanmakuSettingsHandler(svc)(c)
@@ -158,7 +154,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if cfg.Enabled || cfg.Opacity != "0.6" || cfg.FontSize != "32" || cfg.Area != "0.7" {
t.Fatalf("unexpected render config: %+v", cfg)
}
if !cfg.MergeSources || cfg.Volume != 0.35 || cfg.PlaybackRate != 1.5 {
if !cfg.MergeSources || cfg.Volume != 0.35 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
@@ -169,7 +165,7 @@ func TestUpdateDanmakuSettingsPersistsAllPlayerPreferences(t *testing.T) {
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.PlayerPlaybackRate != 1.5 || user.DanmakuSource != "https://dm.example/base" {
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
@@ -207,22 +203,36 @@ func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
}
}
func TestUpdateDanmakuSettingsRejectsInvalidPlaybackRate(t *testing.T) {
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
// playback_rate,服务端应当忽略而不是报错。
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"playback_rate":4}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
c, w := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
getDanmakuConfigHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if bytes.Contains(w.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("config must not expose playback rate: %s", w.Body.String())
}
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"volume":0.5,"playback_rate":1.5}`, "user-1")
updateDanmakuSettingsHandler(svc)(c2)
if w2.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w2.Code, w2.Body.String())
}
if bytes.Contains(w2.Body.Bytes(), []byte("playback_rate")) {
t.Fatalf("settings response must not expose playback rate: %s", w2.Body.String())
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read user: %v", err)
}
if user.PlayerPlaybackRate != 1 {
t.Fatalf("invalid playback rate was persisted: %v", user.PlayerPlaybackRate)
if user.PlayerVolume != 0.5 {
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
}
}
+173
View File
@@ -0,0 +1,173 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 设备管理接口。
//
// 普通用户只能操作自己的设备(路由挂在 /me 下,用户 ID 始终取自会话);
// 管理员通过 /admin/users/:id/devices 代管任意用户。两组接口共用同一份
// DeviceService,因此「谁上线过、谁被踢掉」只有一处事实来源。
// deviceListPayload 是设备列表的下发形状。Fingerprint 不外发:它是防共享
// 判定用的内部标识,暴露出去只会方便伪造。
type deviceListPayload struct {
Devices []devicePayload `json:"devices"`
}
type devicePayload struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name,omitempty"`
Client string `json:"client,omitempty"`
LastIP string `json:"last_ip,omitempty"`
LastSeenAt string `json:"last_seen_at,omitempty"`
LastPlayAt string `json:"last_play_at,omitempty"`
Kicked bool `json:"kicked"`
Online bool `json:"online"`
Playing bool `json:"playing"`
Warnings int `json:"warnings"`
}
func toDevicePayload(d model.UserDevice) devicePayload {
out := devicePayload{
ID: d.ID,
DeviceID: d.DeviceID,
DeviceName: d.DeviceName,
Client: d.Client,
LastIP: d.LastIP,
Kicked: d.Kicked,
Online: d.Online,
Playing: d.Playing,
Warnings: d.Warnings,
}
if !d.LastSeenAt.IsZero() {
out.LastSeenAt = d.LastSeenAt.Format("2006-01-02T15:04:05Z07:00")
}
if d.LastPlayAt != nil && !d.LastPlayAt.IsZero() {
out.LastPlayAt = d.LastPlayAt.Format("2006-01-02T15:04:05Z07:00")
}
return out
}
func deviceListResponse(devices []model.UserDevice) deviceListPayload {
items := make([]devicePayload, 0, len(devices))
for _, d := range devices {
items = append(items, toDevicePayload(d))
}
return deviceListPayload{Devices: items}
}
// myDevicesHandler 返回当前会话用户的设备列表。
func myDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// myKickDeviceHandler 踢掉当前用户的一台设备。
func myKickDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// myKickAllDevicesHandler 踢掉当前用户的全部设备。
func myKickAllDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := sessionUserID(c)
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminUserDevicesHandler 返回指定用户的设备列表。
func adminUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
// FindByID 对「不存在」返回 (nil, nil),必须判空而不是判 error,
// 否则「用户不存在」会伪装成「该用户没有设备」的空列表。
user, err := svc.Repo.User.FindByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
devices, err := svc.Device.ListDevices(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, deviceListResponse(devices))
}
}
// adminKickUserDeviceHandler 由管理员踢掉指定用户的一台设备。
func adminKickUserDeviceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
deviceID := strings.TrimSpace(c.Param("deviceID"))
if deviceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "device id required"})
return
}
if err := svc.Device.KickDevice(c.Request.Context(), userID, deviceID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// adminKickAllUserDevicesHandler 由管理员踢掉指定用户的全部设备。
func adminKickAllUserDevicesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := strings.TrimSpace(c.Param("id"))
if err := svc.Device.KickAllDevices(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// sessionUserID 读取会话用户 ID。调用方路由都挂在鉴权中间件之后,因此这里
// 只做类型断言兜底,不做权限判断。
func sessionUserID(c *gin.Context) string {
if v, ok := c.Get(middleware.CtxUserID); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newDeviceTestEnv 搭一个只挂设备/Telegram 路由的最小环境,并预置两个用户,
// 用于验证「只能操作自己的设备」这条边界。
func newDeviceTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.UserDevice{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Device = service.NewDeviceService(zap.NewNop(), repos)
svc.Device.SetSessionTracker(service.NewSessionTrackerService(zap.NewNop()))
svc.Telegram = service.NewTelegramService(zap.NewNop(), repos)
const secret = "test-secret"
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
// 测试里直接注入会话身份,绕开真实 JWT 解析。
if uid := c.GetHeader("X-Test-User"); uid != "" {
c.Set(middleware.CtxUserID, uid)
c.Set(middleware.CtxUserRole, c.GetHeader("X-Test-Role"))
}
c.Next()
})
authed.GET("/me/devices", myDevicesHandler(svc))
authed.POST("/me/devices/kick-all", myKickAllDevicesHandler(svc))
authed.POST("/me/devices/:deviceID/kick", myKickDeviceHandler(svc))
authed.GET("/me/telegram", getTelegramStatusHandler(svc))
authed.POST("/me/telegram/bind-code", startTelegramBindHandler(svc))
authed.DELETE("/me/telegram", unbindTelegramHandler(svc))
authed.GET("/admin/users/:id/devices", adminUserDevicesHandler(svc))
authed.POST("/admin/users/:id/devices/:deviceID/kick", adminKickUserDeviceHandler(svc))
_ = secret
return router, svc
}
func seedDeviceUsers(t *testing.T, svc *service.Container) {
t.Helper()
ctx := context.Background()
for _, id := range []string{"user-a", "user-b"} {
if err := svc.Repo.User.Create(ctx, &model.User{
Base: model.Base{ID: id}, Username: id, PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
}
}
func doJSON(t *testing.T, router *gin.Engine, method, path, userID, role string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Test-User", userID)
if role != "" {
req.Header.Set("X-Test-Role", role)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// /me/devices 只能返回调用者自己的设备。
func TestMyDevicesScopedToCaller(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
ctx := context.Background()
svc.Device.RecordLogin(ctx, "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
svc.Device.RecordLogin(ctx, "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodGet, "/api/me/devices", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload struct {
Devices []struct {
DeviceID string `json:"device_id"`
} `json:"devices"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Devices) != 1 || payload.Devices[0].DeviceID != "dev-a" {
t.Fatalf("devices = %+v, want only dev-a", payload.Devices)
}
// 设备指纹属于内部判定标识,不能下发。
if strings.Contains(w.Body.String(), "fingerprint") {
t.Fatalf("response must not expose fingerprint: %s", w.Body.String())
}
}
// 踢别人的设备必须失败:/me 路由用会话身份,deviceID 属于他人时查不到。
func TestKickForeignDeviceFails(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-b", "dev-b", "B-Phone", "Infuse", "2.2.2.2")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-b/kick", "user-a", "user")
if w.Code == http.StatusNoContent {
t.Fatal("user-a must not be able to kick user-b's device")
}
}
func TestMyKickOwnDeviceSucceeds(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
svc.Device.RecordLogin(context.Background(), "user-a", "dev-a", "A-Phone", "Infuse", "1.1.1.1")
w := doJSON(t, router, http.MethodPost, "/api/me/devices/dev-a/kick", "user-a", "user")
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// 管理员接口对不存在的用户返回 404,避免把「用户不存在」和「用户没有设备」
// 混成同一个空列表。
func TestAdminDevicesUnknownUserReturns404(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/admin/users/nope/devices", "admin-1", "admin")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestTelegramStatusAndBindCode(t *testing.T) {
router, svc := newDeviceTestEnv(t)
seedDeviceUsers(t, svc)
w := doJSON(t, router, http.MethodGet, "/api/me/telegram", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if !strings.Contains(w.Body.String(), `"bound":false`) {
t.Fatalf("body = %s, want bound=false", w.Body.String())
}
w = doJSON(t, router, http.MethodPost, "/api/me/telegram/bind-code", "user-a", "user")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var code struct {
Code string `json:"code"`
ExpiresIn int `json:"expires_in_seconds"`
}
if err := json.Unmarshal(w.Body.Bytes(), &code); err != nil {
t.Fatal(err)
}
if len(code.Code) != 6 {
t.Fatalf("code = %q, want 6 chars", code.Code)
}
if code.ExpiresIn <= 0 {
t.Fatalf("expires_in_seconds = %d, want > 0", code.ExpiresIn)
}
}
func TestAdminSettingsMasksBotToken(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, "123456:AAHsecretTOKEN"); err != nil {
t.Fatal(err)
}
router.GET("/api/admin/settings", listSettingsHandler(svc))
router.PUT("/api/admin/settings", updateSettingHandler(svc))
w := doJSON(t, router, http.MethodGet, "/api/admin/settings", "admin-1", "admin")
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
if strings.Contains(w.Body.String(), "AAHsecretTOKEN") {
t.Fatalf("bot token leaked: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "***") {
t.Fatalf("bot token should be masked: %s", w.Body.String())
}
}
// 把脱敏值原样提交回来时,必须保留库里真实 Token —— 否则一次保存就把凭据毁掉。
func TestSavingMaskedTokenKeepsRealValue(t *testing.T) {
router, svc := newDeviceTestEnv(t)
ctx := context.Background()
const real = "123456:AAHsecretTOKEN"
if err := svc.Repo.Setting.Set(ctx, service.SettingTelegramBotToken, real); err != nil {
t.Fatal(err)
}
router.PUT("/api/admin/settings", updateSettingHandler(svc))
body := `{"key":"telegram.bot_token","value":"12***EN"}`
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Test-User", "admin-1")
req.Header.Set("X-Test-Role", "admin")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
got, err := svc.Repo.Setting.Get(ctx, service.SettingTelegramBotToken)
if err != nil {
t.Fatal(err)
}
if got != real {
t.Fatalf("stored token = %q, want the original value preserved", got)
}
}
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// Emby 发现类接口的 handler:NextUp / Similar / Genres。
//
// 这三个接口此前返回空列表,导致第三方客户端首页「接下来播放」、详情页
// 「相似推荐」、按类型浏览全部为空白。它们必须始终返回 200 + 合法信封,
// 因为客户端在首页刷新时会并发请求,任何 4xx/5xx 都会被判定为服务端异常。
func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
// YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
if seriesID == "" {
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
seriesID = parentID
}
}
}
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
userID := embyScopedUserID(c)
if userID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
seriesID := strings.TrimSpace(c.Param("id"))
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embySimilarHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
mediaID := strings.TrimSpace(c.Param("id"))
if mediaID == "" {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
out, err := svc.Emby.SimilarItems(c.Request.Context(), mediaID, embyEffectiveUserID(c), limit)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
embyAttachRequestTokenToMediaSources(c, out)
c.JSON(http.StatusOK, out)
}
}
func embyGenresHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid")
out, err := svc.Emby.Genres(c.Request.Context(), embyEffectiveUserID(c), parentID)
if err != nil {
c.JSON(http.StatusOK, embyEmptyItemsPayload())
return
}
c.JSON(http.StatusOK, out)
}
}
// embyScopedUserID 解析「按用户请求」的 Emby 接口的生效用户。
//
// 路由上带 :userId 时(/Users/{uid}/Shows/NextUp),只允许查询自己:客户端
// 偶尔会带着别人的 id 请求,直接采信等于开放他人观看历史的读取。管理员同样
// 按自己处理,避免出现一条无人使用的越权路径。
func embyScopedUserID(c *gin.Context) string {
caller := embyEffectiveUserID(c)
requested := strings.TrimSpace(c.Param("userId"))
if requested == "" {
return caller
}
if requested == caller {
return caller
}
return ""
}
// embyEmptyItemsPayload 与 embyEmptyItemsHandler 保持同一形状。
func embyEmptyItemsPayload() gin.H {
return gin.H{"Items": []any{}, "TotalRecordCount": 0}
}
+372
View File
@@ -0,0 +1,372 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newEmbyDiscoveryEnv 搭一个跑在内存库上的 Emby 路由环境。
func newEmbyDiscoveryEnv(t *testing.T) (*gin.Engine, *service.Container, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{},
&model.Setting{}, &model.Favorite{}, &model.UserDevice{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Emby = service.NewEmbyService(cfg, zap.NewNop(), repos).
SetDiscovery(service.NewMediaDiscoveryService(zap.NewNop(), repos))
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x",
Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, svc)
return router, svc, userID
}
func seedEmbyLibrary(t *testing.T, svc *service.Container, typ string) string {
t.Helper()
lib := &model.Library{Name: "库-" + typ, Path: "/media/" + typ, Type: typ, Enabled: true}
if err := svc.Repo.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
return lib.ID
}
func embyGet(t *testing.T, router *gin.Engine, path, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
if token != "" {
req.Header.Set("X-Emby-Token", token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decodeItemsEnvelope(t *testing.T, body []byte) []map[string]any {
t.Helper()
var payload struct {
Items []map[string]any `json:"Items"`
TotalRecordCount int64 `json:"TotalRecordCount"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", string(body), err)
}
return payload.Items
}
// NextUp 必须真的返回下一集,而不是空数组。
func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
watchedAt := time.Now().Add(-time.Hour)
for episode, watched := range map[int]bool{1: true, 2: false, 3: false} {
m := &model.Media{
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
SeasonNum: 1, EpisodeNum: episode,
Path: "/media/tv/S1E" + string(rune('0'+episode)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
if watched {
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
}
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
}
}
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
episodeIDs := map[int]string{}
for ep := 1; ep <= 3; ep++ {
m := &model.Media{
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
SeasonNum: 1, EpisodeNum: ep,
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
episodeIDs[ep] = m.ID
}
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
WatchedAt: time.Now(), Completed: false,
}).Error; err != nil {
t.Fatal(err)
}
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], w.Body.String())
}
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
}
}
// 没有历史时必须返回合法空信封,不能 404/500。
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
// 小写别名路由同样要走到真实实现(客户端路径大小写并不统一)。
func TestEmbyNextUpLowercaseAlias(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
_ = seedEmbyLibrary(t, svc, "tv")
w := embyGet(t, router, "/emby/shows/nextup", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
}
// Similar 对不存在的条目返回空列表(客户端详情页会无条件请求)。
func TestEmbySimilarUnknownItemReturnsEmpty(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/does-not-exist/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
func TestEmbySimilarReturnsCandidates(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
source := &model.Media{
LibraryID: libID, Title: "源片", Genres: "Action", Year: 2010, Rating: 8,
Path: "/media/movie/source.mkv",
}
if err := svc.Repo.DB.Create(source).Error; err != nil {
t.Fatal(err)
}
other := &model.Media{
LibraryID: libID, Title: "同类片", Genres: "Action", Year: 2011, Rating: 8,
Path: "/media/movie/other.mkv",
}
if err := svc.Repo.DB.Create(other).Error; err != nil {
t.Fatal(err)
}
w := embyGet(t, router, "/emby/Items/"+source.ID+"/Similar", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
}
if name, _ := items[0]["Name"].(string); name != "同类片" {
t.Fatalf("Name = %q, want 同类片", name)
}
}
// Genres 必须返回真实类型与计数。
func TestEmbyGenresReturnsCounts(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "movie")
for i, genres := range []string{"Action,Drama", "Action"} {
m := &model.Media{
LibraryID: libID, Title: "片" + string(rune('A'+i)), Genres: genres,
Path: "/media/movie/m" + string(rune('0'+i)) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
}
w := embyGet(t, router, "/emby/Genres", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 2 {
t.Fatalf("items = %d, want 2 (body=%s)", len(items), w.Body.String())
}
// 排序按计数降序:Action(2) 在前。
if name, _ := items[0]["Name"].(string); name != "Action" {
t.Fatalf("first Name = %q, want Action", name)
}
if count, ok := items[0]["ItemCount"].(float64); !ok || int(count) != 2 {
t.Fatalf("ItemCount = %v, want 2", items[0]["ItemCount"])
}
if id, _ := items[0]["Id"].(string); len(id) == 0 {
t.Fatal("genre item must carry a stable Id")
}
}
// 按别人的 userId 请求 NextUp 不允许泄露他人历史。
func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Users/someone-else/Shows/NextUp", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if items := decodeItemsEnvelope(t, w.Body.Bytes()); len(items) != 0 {
t.Fatalf("items = %d, want 0", len(items))
}
}
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
router, svc, userID := newEmbyDiscoveryEnv(t)
libID := seedEmbyLibrary(t, svc, "tv")
recent := time.Now().Add(-time.Minute)
older := time.Now().Add(-2 * time.Hour)
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
t.Helper()
for ep := 1; ep <= 3; ep++ {
m := &model.Media{
LibraryID: libID, SeriesID: seriesID, Title: title,
SeasonNum: 1, EpisodeNum: ep,
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
}
if err := svc.Repo.DB.Create(m).Error; err != nil {
t.Fatal(err)
}
switch ep {
case 1:
watchedID = m.ID
h := &model.PlaybackHistory{
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
}
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
case 2:
nextID = m.ID
}
}
return watchedID, nextID
}
_, _ = seedSeries("series-hot", "热门剧", recent)
_, wantNext := seedSeries("series-cold", "目标剧", older)
token := signedTestToken(t, "test-secret")
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
if global.Code != http.StatusOK {
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
}
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
}
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
if scoped.Code != http.StatusOK {
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
}
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
if len(items) != 1 {
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
}
if id, _ := items[0]["Id"].(string); id != wantNext {
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
}
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
}
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
if empty.Code != http.StatusOK {
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
}
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
}
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
if pathScoped.Code != http.StatusOK {
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
}
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
if len(pathItems) != 1 {
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
}
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
}
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "movie")
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: libID,
Title: "Movie One",
Path: "/media/movie/one.mp4",
Year: 2024,
ReleaseDate: "2024-01-01",
}).Error; err != nil {
t.Fatalf("create movie: %v", err)
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
t.Fatalf("unexpected item payload: %#v", items[0])
}
}
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
router, svc, _ := newEmbyDiscoveryEnv(t)
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
t.Fatalf("migrate: %v", err)
}
libID := seedEmbyLibrary(t, svc, "tv")
for _, ep := range []struct {
id string
num int
path string
}{
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
} {
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
Base: model.Base{ID: ep.id},
LibraryID: libID,
SeriesID: "series-1",
Title: "Show",
Path: ep.path,
SeasonNum: 1,
EpisodeNum: ep.num,
Year: 2024,
}).Error; err != nil {
t.Fatalf("create episode: %v", err)
}
}
path := "/emby/Items?ParentId=" + libID +
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
items := decodeItemsEnvelope(t, w.Body.Bytes())
if len(items) != 1 {
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
}
if items[0]["Type"] != "Series" {
t.Fatalf("expected Series card, got %#v", items[0])
}
}
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
// 返回 {"Items":[]} 会直接抛 type cast 异常。
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
router, _, _ := newEmbyDiscoveryEnv(t)
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
}
var items []any
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
}
if len(items) != 0 {
t.Fatalf("expected empty array, got %d entries", len(items))
}
}
+19
View File
@@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s
embyServePlaceholderImage(c)
return
}
// 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder
// image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。
if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") {
if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil &&
strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 {
width, height := libraryCoverRequestSize(c)
if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 {
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(http.StatusOK)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
return
}
}
}
// PersonImageURL handles TMDb/remote people first and falls back to regular
// media/library artwork, so the official /Items/{personId}/Images route
// works for synthetic person IDs as well as normal item IDs.
+17 -2
View File
@@ -132,7 +132,10 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -228,12 +231,24 @@ func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
// 不一致时把结果过滤成空集。
seasonIndex = nil
}
// Emby 客户端按 StartIndex/Limit 分页拉取分集,并在已收条目数小于
// TotalRecordCount 时继续请求下一页。忽略这两个参数会让客户端永远
// 停在第一页、反复重发同一请求(现象是分集列表一直加载不出来)。
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "500"))
if limit <= 0 {
limit = 500
}
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
if startIndex < 0 {
startIndex = 0
}
params := service.ItemsParams{
UserID: embyEffectiveUserID(c),
ParentID: parentID,
IncludeItemTypes: []string{"Episode"},
Recursive: true,
Limit: 500,
Limit: limit,
StartIndex: startIndex,
SeasonIndex: seasonIndex,
}
out, err := svc.Emby.Items(c.Request.Context(), params)
+157
View File
@@ -0,0 +1,157 @@
// Package handler — Emby / Jellyfin 媒体分段(片头、片尾)兼容接口。
//
// GET /MediaSegments/{itemId}
// GET /Items/{itemId}/MediaSegments
// GET /Users/{userId}/Items/{itemId}/MediaSegments
//
// 契约对齐 Jellyfin 10.10 引入的 Media Segments API(Emby 采用同一形状),
// 也是 TheIntroDB 官方 Jellyfin 插件走的同一条路:
//
// QueryResult<MediaSegmentDto> = {"Items": [...], "TotalRecordCount": N}
// MediaSegmentDto = {"Id", "ItemId", "Type", "StartTicks", "EndTicks"}
//
// 时间是 .NET ticks(1 tick = 100ns,即每秒 10,000,000、每毫秒 10,000);
// Type 是枚举名字符串 Intro / Outro / Recap / Preview / Commercial。
package handler
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 第三方客户端会在起播前后同步请求分段,不能被一次外网抓取无限拖住。超时后
// 退回已有缓存(可能为空),请求本身永远不失败。
const embyMediaSegmentsFetchBudget = 5 * time.Second
// 1 秒 = 10,000,000 ticks => 1 毫秒 = 10,000 ticks。
const embyTicksPerMillisecond int64 = 10_000
func embyMediaSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 未知条目、虚拟剧集/季、远程 Emby 挂载、以及当前不可见的内容一律返回
// 空结果而不是 404:客户端会把 404 判成「条目损坏」(同 emby_routes.go
// 里 AdditionalParts 的说明),而「没有可跳过的片段」本来就是个合法状态。
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
if svc == nil || svc.Repo == nil || svc.Segments == nil {
c.JSON(http.StatusOK, empty)
return
}
id := c.Param("id")
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if service.IsEmbyRemoteID(id) {
c.JSON(http.StatusOK, empty)
return
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusOK, empty)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), embyMediaSegmentsFetchBudget)
defer cancel()
rows, listErr := svc.Segments.ListForPlayback(ctx, m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("emby media segments lookup failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
items := embySegmentItems(m, rows, embyRequestedSegmentTypes(c))
c.JSON(http.StatusOK, gin.H{"Items": items, "TotalRecordCount": len(items)})
}
}
// embySegmentItems 把库内片段转换成 MediaSegmentDto 列表。
func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string]bool) []gin.H {
// 末段在库内用 end_ms = 0 表示「一直到片尾」(TheIntroDB 对片尾返回 end_ms: null),
// 这里必须换算成真实结束时间;拿不到时长就丢弃该分段,否则会给出一个零长度区间,
// 客户端要么忽略要么画出一个错误的跳转点。
durationMs := int64(m.DurationSec) * 1000
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
typeName := embySegmentTypeName(row.Kind)
if typeName == "" {
continue
}
if len(want) > 0 && !want[typeName] {
continue
}
endMs := row.EndMs
if endMs <= 0 {
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
// 整集标成片头,所以直接丢弃。
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
continue
}
if durationMs <= 0 {
continue
}
endMs = durationMs
}
if endMs <= row.StartMs {
continue
}
items = append(items, gin.H{
"Id": row.ID,
"ItemId": m.ID,
"Type": typeName,
"StartTicks": row.StartMs * embyTicksPerMillisecond,
"EndTicks": endMs * embyTicksPerMillisecond,
})
}
return items
}
// embySegmentTypeName 把库内 kind 映射成 Emby/Jellyfin 的 MediaSegmentType 名字。
// 库内的 credits 取自 TheIntroDB 的字段名,在 Emby 一侧对应 Outro。
func embySegmentTypeName(kind string) string {
switch kind {
case model.SegmentKindIntro:
return "Intro"
case model.SegmentKindRecap:
return "Recap"
case model.SegmentKindCredits:
return "Outro"
case model.SegmentKindPreview:
return "Preview"
default:
return ""
}
}
// embyRequestedSegmentTypes 解析 includeSegmentTypes(Jellyfin 的过滤参数)。
// 支持重复参数与逗号分隔两种写法;返回空集合表示不过滤。
//
// 只认枚举名字符串。数字枚举虽然 ASP.NET 模型绑定也接受,但各家定义的顺序并
// 不一致,猜错会把过滤结果算错;认不出来时按「不过滤」处理,返回的是超集,
// 客户端自己仍会再过滤一次。
func embyRequestedSegmentTypes(c *gin.Context) map[string]bool {
raw := make([]string, 0, 4)
for _, key := range []string{"includeSegmentTypes", "IncludeSegmentTypes", "includesegmenttypes"} {
raw = append(raw, c.QueryArray(key)...)
}
want := make(map[string]bool, len(raw))
for _, value := range raw {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
for _, name := range []string{"Intro", "Outro", "Recap", "Preview", "Commercial"} {
if strings.EqualFold(part, name) {
want[name] = true
}
}
}
}
return want
}
@@ -0,0 +1,217 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
type embySegmentsPayload struct {
Items []struct {
ID string `json:"Id"`
ItemID string `json:"ItemId"`
Type string `json:"Type"`
StartTicks int64 `json:"StartTicks"`
EndTicks int64 `json:"EndTicks"`
} `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
const (
embySegmentsMovieDurationSec = 8880
embySegmentsTicksPerSecond = 10_000_000
)
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: lib.ID,
Title: "Inception",
Path: "D:\\media\\movies\\Inception.mkv",
DurationSec: durationSec,
TMDbID: 27205,
}).Error; err != nil {
t.Fatal(err)
}
var calls int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(embySegmentsProviderBody))
}))
t.Cleanup(provider.Close)
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Segments: segments,
Log: zap.NewNop(),
})
return router, secret, &calls
}
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
}
var payload embySegmentsPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
return payload
}
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
}
intro := payload.Items[0]
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
t.Fatalf("intro segment = %#v", intro)
}
// start_ms null 表示从片头开始。
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
t.Fatalf("intro ticks = %d..%d, want 0..%d",
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
}
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
// 否则客户端会拿到一个零长度区间。
outro := payload.Items[1]
if outro.Type != "Outro" {
t.Fatalf("credits should map to Outro, got %q", outro.Type)
}
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
}
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
t.Fatalf("outro EndTicks = %d, want the media duration %d",
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
}
}
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
for _, path := range []string{
"/MediaSegments/movie-1",
"/mediasegments/movie-1",
"/Items/movie-1/MediaSegments",
"/items/movie-1/mediasegments",
"/Users/user-1/Items/movie-1/MediaSegments",
} {
payload := embySegmentsRequest(t, router, secret, path)
if len(payload.Items) != 2 {
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
}
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
}
}
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
}
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
if payload.TotalRecordCount != 2 {
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
}
}
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
// 只能丢弃;有明确结束点的 intro 必须保留。
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
}
}
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
t.Fatalf("unknown item payload = %#v", payload)
}
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
if len(payload.Items) != 0 {
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
}
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
}
}
+26
View File
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
}
}
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
//
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
return
}
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
}
}
type reorderEmbyMountsReq struct {
IDs []string `json:"ids" binding:"required"`
}
@@ -0,0 +1,108 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
// 只影响目标账号,且配置保留(不是删除)。
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
ctx := t.Context()
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
for _, m := range []*model.EmbyMount{m1, m2, other} {
if err := repos.EmbyMount.Create(ctx, m); err != nil {
t.Fatalf("create mount: %v", err)
}
}
svc := &service.Container{
Repo: repos,
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
}
router := gin.New()
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
call := func(body map[string]any) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
w := call(map[string]any{"enabled": false})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Affected int64 `json:"affected"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !resp.OK || resp.Affected != 2 {
t.Fatalf("expected ok with affected=2, got %+v", resp)
}
rows, err := repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
if row.Enabled != want {
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
}
}
// 重新启用:挂载记录仍在,只是开关回 true。
w = call(map[string]any{"enabled": true})
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
rows, err = repos.EmbyMount.List(ctx)
if err != nil {
t.Fatal(err)
}
if len(rows) != 3 {
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
}
for _, row := range rows {
if !row.Enabled {
t.Fatalf("mount %s should be enabled again", row.ID)
}
}
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
}
}
+109 -8
View File
@@ -1,10 +1,13 @@
package handler
import (
"context"
"errors"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -24,10 +27,100 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return
}
embyAttachRequestTokenToMediaSources(c, out)
// 在后台把本次条目的云盘直链换好:播放器拿到 PlaybackInfo 后通常还要
// 1–2 秒才请求 /Videos/{id}/stream,把换链开销落在这段等待里。
embyPrewarmPlaybackTargets(svc, c, out)
c.JSON(http.StatusOK, out)
}
}
// embyPrewarmTimeout 是单次预热的等待上限。115 开放平台在跨太平洋线路上单次
// 换链实测 0.4–1.1s,这里给足余量;超时只是没预热成功,不影响后续播放。
const embyPrewarmTimeout = 10 * time.Second
// embyPrewarmInFlight 去重同一个条目的并发预热(首页刷新会并发请求多个接口,
// 同一条目可能在短时间内被多次请求)。
var embyPrewarmInFlight sync.Map
// embyPrewarmSlots 限制同时进行的预热数量。客户端可能批量预取 PlaybackInfo
// (逐个剧集的预取请求),预热只是优化,不能反过来把 115 换链接口打出突发。
// 名额满时直接跳过:排队等待的预热往往等真正播放时已经没意义了。
var embyPrewarmSlots = make(chan struct{}, 4)
// embyPrewarmPlaybackTargets 在后台预热本次 PlaybackInfo 涉及条目的云盘直链。
//
// 起播链路里最贵的一步是「服务端拿 pickcode 去 115 开放平台换直链」:服务器在
// 洛杉矶、115 接口在国内,冷启动实测 0.4–1.1s;之后 45 分钟内命中进程内缓存。
// 播放器在 PlaybackInfo 与真正拉流之间有几秒间隔,这里把换链放到那段间隔里,
// 起播时就只剩纯网络耗时。
//
// 只处理云盘/strm 条目,且失败一律静默忽略:预热是尽力而为的优化,不能影响
// PlaybackInfo 的正常返回。
func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[string]any) {
if svc == nil || svc.Strm == nil || svc.Repo == nil || svc.Repo.Media == nil || out == nil {
return
}
ids := embyPrewarmMediaIDs(out)
if len(ids) == 0 {
return
}
userAgent := c.GetHeader("User-Agent")
// 预热是给「后续请求」用的:即便本次 PlaybackInfo 的连接断开,
// 也要把换链跑完。
base := context.WithoutCancel(c.Request.Context())
for _, mediaID := range ids {
if _, loaded := embyPrewarmInFlight.LoadOrStore(mediaID, struct{}{}); loaded {
continue
}
go func(id string) {
defer embyPrewarmInFlight.Delete(id)
select {
case embyPrewarmSlots <- struct{}{}:
defer func() { <-embyPrewarmSlots }()
default:
return
}
ctx, cancel := context.WithTimeout(base, embyPrewarmTimeout)
defer cancel()
m, err := svc.Repo.Media.FindByID(ctx, id)
if err != nil || m == nil {
return
}
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
raw := service.MediaSTRMTarget(m)
if raw == "" || !service.IsStrmMediaRow(m) {
return
}
// 解析结果由 strm 层按 pickcode+UA 缓存;已缓存时这里是空转。
_, _ = svc.Strm.ResolvePlayTargetWithUA(ctx, raw, userAgent)
}(mediaID)
}
}
// embyPrewarmMediaIDs 取出 PlaybackInfo 载荷里 MediaSources 的条目 ID。
func embyPrewarmMediaIDs(out map[string]any) []string {
sources, ok := out["MediaSources"].([]map[string]any)
if !ok || len(sources) == 0 {
return nil
}
ids := make([]string, 0, len(sources))
seen := make(map[string]struct{}, len(sources))
for _, src := range sources {
id, _ := src["Id"].(string)
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids
}
// embySubtitleStreamHandler serves an external subtitle track advertised in a
// MediaSource's MediaStreams via its Emby index
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
@@ -318,13 +411,18 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
// 转码播放下每个分片请求都跑一遍太浪费。
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"))
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
err = svc.Stream.ServeHLSPlaylist(c.Writer, c.Request, c.Param("id"), scope)
if errors.Is(err, service.ErrTranscodeDisabled) {
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
return
@@ -345,13 +443,16 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg")); err != nil {
scope, ok := localHLSPlaybackScope(c)
if !ok {
return
}
if err := svc.Stream.ServeHLSSegment(c.Writer, c.Request, c.Param("id"), c.Param("seg"), scope); err != nil {
c.Status(http.StatusNotFound)
}
}
@@ -0,0 +1,159 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func newPrewarmTestContainer(t *testing.T) *service.Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}, &model.StrmAccount{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
// 内存库 + 后台预热协程:限制单连接,避免新连接拿到空白的 :memory:。
sqlDB.SetMaxOpenConns(1)
}
repos := repository.New(db)
return &service.Container{
Log: zap.NewNop(),
Repo: repos,
Strm: service.NewStrmService(&config.Config{}, zap.NewNop(), repos, nil),
}
}
func newPrewarmTestContext() *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/emby/Items/media-1/PlaybackInfo", nil)
c.Request.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
return c
}
func TestEmbyPrewarmMediaIDsExtractsDeduplicates(t *testing.T) {
out := map[string]any{
"MediaSources": []map[string]any{
{"Id": "src-1"},
{"Id": " src-1 "},
{"Id": "src-2"},
{"Id": ""},
{"Name": "no id"},
},
}
got := embyPrewarmMediaIDs(out)
if len(got) != 2 || got[0] != "src-1" || got[1] != "src-2" {
t.Fatalf("ids = %v, want [src-1 src-2]", got)
}
if got := embyPrewarmMediaIDs(map[string]any{}); len(got) != 0 {
t.Fatalf("missing MediaSources should yield no ids, got %v", got)
}
if got := embyPrewarmMediaIDs(map[string]any{"MediaSources": []any{}}); len(got) != 0 {
t.Fatalf("foreign payload shape should yield no ids, got %v", got)
}
}
// 预热是异步的:调用必须立即返回,并且协程结束后不能残留去重标记。
func TestEmbyPrewarmPlaybackTargetsRunsAsyncAndCleansUp(t *testing.T) {
svc := newPrewarmTestContainer(t)
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "media-1"},
Title: "Cloud",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=missing&pickcode=pc1",
}).Error; err != nil {
t.Fatal(err)
}
embyPrewarmInFlight.Delete("media-1")
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
done := make(chan struct{})
go func() {
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(), out)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("embyPrewarmPlaybackTargets blocked the caller")
}
// 后台协程应很快跑完并释放去重标记,否则同一条目后续再也预热不了。
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if _, busy := embyPrewarmInFlight.Load("media-1"); !busy {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("prewarm in-flight marker leaked")
}
// 各种缺数据的情况都不允许 panic 或阻塞:预热只是尽力而为的优化。
func TestEmbyPrewarmPlaybackTargetsIsNilSafe(t *testing.T) {
svc := newPrewarmTestContainer(t)
c := newPrewarmTestContext()
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
cases := []struct {
name string
svc *service.Container
out map[string]any
}{
{name: "空容器", svc: &service.Container{}, out: out},
{name: "无 Strm", svc: &service.Container{Repo: svc.Repo}, out: out},
{name: "无 Repo", svc: &service.Container{Strm: svc.Strm}, out: out},
{name: "nil 载荷", svc: svc, out: nil},
{name: "无 MediaSources", svc: svc, out: map[string]any{}},
{name: "条目不存在", svc: svc, out: out},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
embyPrewarmPlaybackTargets(tc.svc, c, tc.out)
})
}
}
// 本地文件条目不该触发换链预热(没有云盘直链可预热)。
func TestEmbyPrewarmSkipsLocalMedia(t *testing.T) {
svc := newPrewarmTestContainer(t)
if err := svc.Repo.DB.Create(&model.Media{
Base: model.Base{ID: "local-1"},
Title: "Local",
Path: "/media/movies/Local.mkv",
LibraryID: "lib-1",
}).Error; err != nil {
t.Fatal(err)
}
embyPrewarmInFlight.Delete("local-1")
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(),
map[string]any{"MediaSources": []map[string]any{{"Id": "local-1"}}})
// 协程要么已经跑完(标记被清掉),要么根本没起;两种都不该留下标记。
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, busy := embyPrewarmInFlight.Load("local-1"); !busy {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("local media must not leave a prewarm marker")
}
+14 -7
View File
@@ -196,20 +196,27 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Seasons", embyShowSeasonsHandler(svc))
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyEmptyItemsHandler(svc))
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Artists", embyEmptyItemsHandler(svc))
auth.GET("/Persons", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyGenresHandler(svc))
auth.GET("/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Users/:userId/Shows/Upcoming", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
}
+11 -7
View File
@@ -40,20 +40,24 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/users/:userId/shows/:id/seasons", embyShowSeasonsHandler(svc))
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyEmptyItemsHandler(svc))
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/artists", embyEmptyItemsHandler(svc))
auth.GET("/persons", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyGenresHandler(svc))
auth.GET("/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/shows/upcoming", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/similar", embySimilarHandler(svc))
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -21,6 +22,31 @@ import (
// two seasons, so /Shows/{id}/Episodes can be exercised with the query forms
// real clients send.
func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
t.Helper()
return embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "s1e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
})
}
// embySeriesRouter builds an Emby-compatible router with a single tv library and
// one series made of the given episode rows. Callers omit LibraryID: it is filled
// in here once the library exists.
func embySeriesRouter(t *testing.T, rows []model.Media) (*gin.Engine, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
@@ -48,27 +74,9 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for _, m := range []model.Media{
{
Base: model.Base{ID: "s1e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 01\\Test Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "s2e1"},
LibraryID: lib.ID,
Title: "Test Show",
Path: "D:\\media\\tv\\Test Show\\Season 02\\Test Show - S02E01.mkv",
SeasonNum: 2,
EpisodeNum: 1,
Container: "mkv",
},
} {
if err := db.Create(&m).Error; err != nil {
for i := range rows {
rows[i].LibraryID = lib.ID
if err := db.Create(&rows[i]).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
@@ -104,7 +112,34 @@ func embySeasonEpisodesRouter(t *testing.T) (*gin.Engine, string, string) {
return router, secret, seriesID
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
func fetchSeasonID(t *testing.T, router *gin.Engine, secret, seriesID string, index int) string {
t.Helper()
path := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
for _, s := range seasons.Items {
if got, ok := s["IndexNumber"].(float64); ok && int(got) == index {
if id, _ := s["Id"].(string); id != "" {
return id
}
}
}
t.Fatalf("season %d not found in %#v", index, seasons.Items)
return ""
}
func fetchEpisodeItems(t *testing.T, router *gin.Engine, secret, path string) ([]map[string]any, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
@@ -120,12 +155,18 @@ func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]s
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode %s: %v", path, err)
}
ids := make([]string, 0, len(payload.Items))
for _, item := range payload.Items {
return payload.Items, int(payload.TotalRecordCount)
}
func fetchEpisodeIDs(t *testing.T, router *gin.Engine, secret, path string) ([]string, int) {
t.Helper()
items, total := fetchEpisodeItems(t, router, secret, path)
ids := make([]string, 0, len(items))
for _, item := range items {
id, _ := item["Id"].(string)
ids = append(ids, id)
}
return ids, int(payload.TotalRecordCount)
return ids, total
}
// TestEmbyShowEpisodesHonoursSeasonQueryParam is the client-facing regression
@@ -174,29 +215,7 @@ func TestEmbyShowEpisodesHonoursSeasonQueryParam(t *testing.T) {
func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
router, secret, seriesID := embySeasonEpisodesRouter(t)
seasonsPath := "/Shows/" + seriesID + "/Seasons"
req := httptest.NewRequest(http.MethodGet, seasonsPath, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("seasons status=%d body=%s", rec.Code, rec.Body.String())
}
var seasons struct {
Items []map[string]any `json:"Items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &seasons); err != nil {
t.Fatalf("decode seasons: %v", err)
}
var season1ID string
for _, s := range seasons.Items {
if index, ok := s["IndexNumber"].(float64); ok && int(index) == 1 {
season1ID, _ = s["Id"].(string)
}
}
if season1ID == "" {
t.Fatalf("season 1 not found in %#v", seasons.Items)
}
season1ID := fetchSeasonID(t, router, secret, seriesID, 1)
ids, _ := fetchEpisodeIDs(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+season1ID)
if len(ids) != 1 || ids[0] != "s1e1" {
@@ -209,3 +228,145 @@ func TestEmbyShowEpisodesSeasonIdStillWins(t *testing.T) {
t.Fatalf("SeasonId+Season episodes = %#v, want [s1e1]", ids)
}
}
// foldedDuplicateSeriesRows models a season where two files resolve to the same
// episode number — 3月的狮子 S01E11 together with S01E11.5, which the episode
// parser reads as S01E11 and stores as a second row for season 1 episode 2 here.
func foldedDuplicateSeriesRows() []model.Media {
return []model.Media{
{
Base: model.Base{ID: "e1"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
Container: "mkv",
},
{
Base: model.Base{ID: "e2"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
{
Base: model.Base{ID: "e2half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E02.5.mkv",
SeasonNum: 1,
EpisodeNum: 2,
Container: "mkv",
},
}
}
// TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent is the regression test
// for the "第一季分集加载不出来" lock-up: two rows sharing one season/episode are
// folded into a single Emby item with two MediaSources. TotalRecordCount used to
// be computed before that fold, so clients that page until they have
// TotalRecordCount items kept re-requesting the same page and never rendered the
// season.
func TestEmbyShowEpisodesFoldedDuplicateKeepsTotalsConsistent(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
seasonID := fetchSeasonID(t, router, secret, seriesID, 1)
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?SeasonId="+seasonID)
if total != len(items) {
t.Fatalf("TotalRecordCount = %d but %d items returned, want them equal", total, len(items))
}
if total != 2 {
t.Fatalf("TotalRecordCount = %d, want 2 distinct episodes", total)
}
// The folded episode must still carry both files so clients can switch version.
versioned := 0
for _, item := range items {
if sources, ok := item["MediaSources"].([]any); ok && len(sources) > 1 {
versioned++
}
}
if versioned != 1 {
t.Fatalf("items with multiple MediaSources = %d, want 1 (%#v)", versioned, items)
}
}
// TestEmbyShowEpisodesPagingAdvances pins that the client's StartIndex/Limit reach
// the episode list. Ignoring them made every page identical, so a client paging
// until it has TotalRecordCount items could never finish loading the season.
func TestEmbyShowEpisodesPagingAdvances(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, foldedDuplicateSeriesRows())
base := "/Shows/" + seriesID + "/Episodes?Season=1"
first, total := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=0&Limit=1")
if total != 2 || len(first) != 1 {
t.Fatalf("page 1 = %#v (total=%d), want one item of two", first, total)
}
second, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=1&Limit=1")
if len(second) != 1 || second[0] == first[0] {
t.Fatalf("page 2 = %#v, want the remaining episode (page 1 = %#v)", second, first)
}
third, _ := fetchEpisodeIDs(t, router, secret, base+"&StartIndex=2&Limit=1")
if len(third) != 0 {
t.Fatalf("page 3 = %#v, want an empty page so clients stop paging", third)
}
}
// 半集(S01E11.5)在 Emby 侧必须和第 11 集并列返回:一旦被折成一个条目,
// TotalRecordCount 就会比 Items 多一条,按总数翻页的客户端会一直重发同一页
// (这就是「第一季分集加载不出来」的根因)。Emby 的 IndexNumber 只能是整数,
// 所以集号仍占 11,小数通过条目名体现。
func TestEmbyShowEpisodesKeepsHalfEpisodeVisible(t *testing.T) {
router, secret, seriesID := embySeriesRouter(t, []model.Media{
{
Base: model.Base{ID: "e11"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.mkv",
SeasonNum: 1,
EpisodeNum: 11,
Container: "mkv",
},
{
Base: model.Base{ID: "e11half"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E11.5.mkv",
SeasonNum: 1,
EpisodeNum: 11,
EpisodeFraction: 0.5,
Container: "mkv",
},
{
Base: model.Base{ID: "e12"},
Title: "三月的狮子",
Path: "D:\\media\\tv\\三月的狮子\\三月的狮子 - S01E12.mkv",
SeasonNum: 1,
EpisodeNum: 12,
Container: "mkv",
},
})
items, total := fetchEpisodeItems(t, router, secret, "/Shows/"+seriesID+"/Episodes?Season=1")
if total != 3 || len(items) != 3 {
t.Fatalf("TotalRecordCount = %d, items = %d, want 3/3", total, len(items))
}
wantOrder := []string{"e11", "e11half", "e12"}
for i, want := range wantOrder {
id, _ := items[i]["Id"].(string)
if id != want {
t.Fatalf("items[%d] = %q, want %q (order %#v)", i, id, want, items)
}
}
for _, item := range items {
sources, _ := item["MediaSources"].([]any)
if len(sources) != 1 {
t.Fatalf("item %v folded %d sources, want 1", item["Id"], len(sources))
}
}
halfName, _ := items[1]["Name"].(string)
if !strings.HasPrefix(halfName, "第 11.5 集") {
t.Fatalf("half episode name = %q, want prefix 第 11.5 集", halfName)
}
if idx, _ := items[1]["IndexNumber"].(float64); int(idx) != 11 {
t.Fatalf("half episode IndexNumber = %v, want 11 (Emby only supports integers)", items[1]["IndexNumber"])
}
}
+16
View File
@@ -26,12 +26,28 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
api.GET("/reader/media", readerMediaProxyHandler(svc))
// 本地书籍内嵌资源(EPUB 图片):同样靠 HMAC 签名鉴权。
api.GET("/reader/local/asset", readerLocalAssetHandler(svc))
// 本地有声书音频流:同样靠 HMAC 签名鉴权,服务端处理 Range。
api.GET("/reader/local/audio", readerLocalAudioHandler(svc))
api.HEAD("/reader/local/audio", readerLocalAudioHandler(svc))
// 需要转码的音轨(WMA 等浏览器解不了的格式):签名鉴权 + 服务端 ffmpeg 转码。
api.GET("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
api.HEAD("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
// 阅读书源浏览器页面与资源代理:java.startBrowserAwait 承载的页面填进
// <iframe src>,同样带不了 JWT,鉴权靠绑定待办 ID 的 HMAC 签名。
api.GET("/reader/browser/page", readerBrowserPageHandler(svc))
api.GET("/reader/browser/asset", readerBrowserAssetHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
registerPublicAuthRoutes(api, svc, log)
registerMobileRoutes(api, svc, log)
registerAuthenticatedRoutes(api, cfg, svc)
registerAdminRoutes(api, cfg, svc)
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
func TestStopHLSChecksMediaVisibility(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Transcoder = service.NewTranscoderService(svc.Cfg, zap.NewNop(), svc.Repo, nil)
router.DELETE("/api/hls/:id", middleware.AuthRequired(secret), stopTranscodeHandler(svc))
req := httptest.NewRequest(http.MethodDelete, "/api/hls/nonexistent?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("stop of a nonexistent/invisible media: status=%d body=%s, want 404", rec.Code, rec.Body.String())
}
profile := &model.PlayProfile{UserID: "user-1", Name: "restricted", IsDefault: true, AllowedLibraryIDs: `["other-library"]`}
if err := svc.Repo.DB.Create(profile).Error; err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodDelete, "/api/hls/media-1?session_id=player-a", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("invisible existing media can be stopped: %d %s", rec.Code, rec.Body.String())
}
}
+100
View File
@@ -0,0 +1,100 @@
package handler
import (
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。
//
// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张
// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成
// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App
// 都指向这一份,外观统一。
func libraryCoverHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" || svc == nil || svc.ImageProxy == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second)
defer cancel()
sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id)
if lib == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "library not found"})
return
}
if strings.TrimSpace(lib.CoverURL) != "" {
// 管理员自定义封面优先,保持既有行为(代理该地址)。
if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"})
}
return
}
if len(sources) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"})
return
}
width, height := libraryCoverRequestSize(c)
data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height)
if err != nil || len(data) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"})
return
}
writeLibraryCoverImage(c, data, http.StatusOK)
}
}
// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用
// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby
// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的
// 边界使用。
func libraryCoverRequestSize(c *gin.Context) (int, int) {
return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"),
firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h")
}
func firstPositiveQueryIntParam(c *gin.Context, names ...string) int {
for _, name := range names {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
continue
}
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
return n
}
if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 {
return int(f)
}
}
return 0
}
func writeLibraryCoverImage(c *gin.Context, data []byte, status int) {
sum := sha256.Sum256(data)
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
c.Header("ETag", etag)
c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400")
if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag {
c.Status(http.StatusNotModified)
return
}
c.Header("Content-Type", "image/jpeg")
c.Header("Content-Length", strconv.Itoa(len(data)))
if c.Request.Method == http.MethodHead {
c.Status(status)
return
}
c.Data(status, "image/jpeg", data)
}
@@ -0,0 +1,118 @@
package handler
import (
"bytes"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestToken(t *testing.T, secret, userID string) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{
UserID: userID,
Role: "admin",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Subject: userID,
},
})
signed, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return signed
}
// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register
// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired +
// activeUserRequired)不会把它挡成 404。
func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) {
gin.SetMode(gin.TestMode)
const secret = "test-secret"
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{
Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")},
Secrets: config.SecretsConfig{JWTSecret: secret},
}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
engine := gin.New()
Register(engine, cfg, zap.NewNop(), svc)
token := coverTestToken(t, secret, user.ID)
req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
}
+228
View File
@@ -0,0 +1,228 @@
package handler
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func coverTestJPEG(t *testing.T, c color.RGBA) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 160, 240))
for y := 0; y < 240; y++ {
for x := 0; x < 160; x++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自
// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。
func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) {
t.Helper()
gin.SetMode(gin.TestMode)
poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
t.Cleanup(upstream.Close)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
parsed, err := url.Parse(upstream.URL)
if err != nil {
t.Fatalf("parse upstream: %v", err)
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{Repo: repos, ImageProxy: proxy}
router := gin.New()
router.GET("/libraries/:id/cover", libraryCoverHandler(svc))
router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc))
return router, repos, upstream
}
func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library {
t.Helper()
lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "m" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Movie " + string(rune('A'+i)),
Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv",
PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
return lib
}
// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口:
// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。
func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, "")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ct)
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode cover: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 {
t.Fatalf("cover size = %v, want 480x270", img.Bounds())
}
if etag := w.Header().Get("ETag"); etag == "" {
t.Fatal("expected an ETag so clients can revalidate")
}
// 条件请求应拿到 304。
req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil)
req.Header.Set("If-None-Match", w.Header().Get("ETag"))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotModified {
t.Fatalf("conditional request status = %d, want 304", rec.Code)
}
}
func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) {
router, _, _ := newLibraryCoverTestRouter(t)
req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续
// 用自定义封面,不能在不知不觉中换成拼图。
func TestLibraryCoverHandlerServesCustomCover(t *testing.T) {
router, repos, upstream := newLibraryCoverTestRouter(t)
lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg")
req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil {
t.Fatalf("custom cover should be an image: %v", err)
}
}
// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时
// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。
func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatalf("migrate: %v", err)
}
poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write(poster)
}))
defer upstream.Close()
parsed, _ := url.Parse(upstream.URL)
repos := repository.New(db)
lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
for i := 0; i < 4; i++ {
row := model.Media{
Base: model.Base{ID: "e" + string(rune('a'+i))},
LibraryID: lib.ID,
Title: "Show " + string(rune('A'+i)),
Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv",
SeasonNum: 1, EpisodeNum: 1,
PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg",
}
if err := repos.DB.Create(&row).Error; err != nil {
t.Fatalf("create media: %v", err)
}
}
cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}
proxy := service.NewImageProxy(cfg, zap.NewNop())
proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} })
svc := &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
ImageProxy: proxy,
}
router := gin.New()
registerEmbyRoutes(router, "test-secret", svc)
req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes()))
if err != nil {
t.Fatalf("decode emby library cover: %v", err)
}
// 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。
if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 {
t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds())
}
}
+55
View File
@@ -0,0 +1,55 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// 媒体库筛选面板接口:facets 提供可选项,random 提供「随便看看」。
//
// 两者都走与列表完全相同的可见性判定(mediaVisibilityForRequest)与筛选解析
// (parseLibraryFilters),因此不会出现「列表里有、facets 里没有」或「随机跳
// 到了筛选条件之外的条目」这类不一致。
func libraryFacetsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
facets, err := svc.Media.LibraryFacets(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
svc.Discovery,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
c.JSON(http.StatusOK, facets)
}
}
func libraryRandomHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Param("id")
filters := parseLibraryFilters(c)
// 随机只取一条,因此不带分页参数;未观看筛选仍需要会话用户。
media, err := svc.Media.RandomMedia(
c.Request.Context(),
libraryID,
mediaVisibilityForRequest(c, svc),
filters,
)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
if media == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no media matches the current filters"})
return
}
c.JSON(http.StatusOK, media)
}
}
+283
View File
@@ -0,0 +1,283 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func newLibraryFilterEnv(t *testing.T) (*gin.Engine, *service.Container, string, string) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(
&model.User{}, &model.Library{}, &model.Media{}, &model.PlaybackHistory{}, &model.Setting{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
svc := &service.Container{Repo: repos, Log: zap.NewNop()}
svc.Media = service.NewMediaService(nil, zap.NewNop(), repos)
svc.Discovery = service.NewMediaDiscoveryService(zap.NewNop(), repos)
const userID = "user-1"
if err := repos.User.Create(context.Background(), &model.User{
Base: model.Base{ID: userID}, Username: "tester", PasswordHash: "x", Role: "user", IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(context.Background(), lib); err != nil {
t.Fatal(err)
}
router := gin.New()
authed := router.Group("/api", func(c *gin.Context) {
c.Set(middleware.CtxUserID, userID)
c.Set(middleware.CtxUserRole, "user")
c.Next()
})
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/facets", libraryFacetsHandler(svc))
authed.GET("/libraries/:id/random", libraryRandomHandler(svc))
return router, svc, userID, lib.ID
}
func seedLibraryMedia(t *testing.T, svc *service.Container, rows ...*model.Media) {
t.Helper()
for _, row := range rows {
if err := svc.Repo.DB.Create(row).Error; err != nil {
t.Fatal(err)
}
}
}
func getJSON(t *testing.T, router *gin.Engine, path string) (int, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w.Code, w.Body.Bytes()
}
func TestLibraryFacetsReturnGenresAndYears(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action,Drama", Year: 1999, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Action", Year: 2021, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var facets struct {
Genres []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"genres"`
YearMin int `json:"year_min"`
YearMax int `json:"year_max"`
}
if err := json.Unmarshal(body, &facets); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if facets.YearMin != 1999 || facets.YearMax != 2021 {
t.Fatalf("year range = %d..%d, want 1999..2021", facets.YearMin, facets.YearMax)
}
if len(facets.Genres) != 2 {
t.Fatalf("genres = %+v, want 2 entries", facets.Genres)
}
if facets.Genres[0].Name != "Action" || facets.Genres[0].Count != 2 {
t.Fatalf("first genre = %+v, want Action:2", facets.Genres[0])
}
}
// 空库时 facets 必须返回空数组而不是 null,前端无需额外判空。
func TestLibraryFacetsEmptyLibrary(t *testing.T) {
router, _, _, libID := newLibraryFilterEnv(t)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/facets")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
if !containsSubstring(string(body), `"genres":[]`) {
t.Fatalf("body = %s, want genres:[]", body)
}
}
// 列表筛选:按类型过滤后只返回命中的条目。
func TestListMediaAppliesGenreFilter(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作", payload)
}
}
// 筛选条件必须进入缓存键:先请求未筛选列表、再筛选时不能命中旧缓存。
func TestListMediaFilterBypassesUnfilteredCache(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// 先拉全量(可能写缓存),再拉筛选结果。
if code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0"); code != http.StatusOK {
t.Fatalf("unfiltered status = %d body=%s", code, body)
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre=Comedy")
if code != http.StatusOK {
t.Fatalf("filtered status = %d body=%s", code, body)
}
var payload struct {
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if payload.Total != 1 {
t.Fatalf("total = %d, want 1 (filtered response must not be served from the unfiltered cache)", payload.Total)
}
}
// 未观看筛选:已看完的不出现,看了一半的仍出现。
func TestListMediaUnwatchedFilter(t *testing.T) {
router, svc, userID, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{Base: model.Base{ID: "m-done"}, LibraryID: libID, Title: "看完", Path: "/a.mkv"},
&model.Media{Base: model.Base{ID: "m-half"}, LibraryID: libID, Title: "看一半", Path: "/b.mkv"},
)
for _, h := range []*model.PlaybackHistory{
{UserID: userID, MediaID: "m-done", Completed: true},
{UserID: userID, MediaID: "m-half", Completed: false},
} {
if err := svc.Repo.DB.Create(h).Error; err != nil {
t.Fatal(err)
}
}
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&unwatched=1")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatal(err)
}
if len(payload.Items) != 1 || payload.Items[0].Title != "看一半" {
t.Fatalf("items = %+v, want only 看一半", payload.Items)
}
}
func TestLibraryRandomReturnsMedia(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "唯一", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if media.Title != "唯一" {
t.Fatalf("title = %q, want 唯一", media.Title)
}
}
// 筛选后没有命中时返回 404,前端据此提示「没有符合条件的媒体」。
func TestLibraryRandomEmptyResultIs404(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Nonexistent")
if code != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", code, body)
}
}
// axios 默认把数组序列化为 genre[]=Action 格式;后端必须把它当作 genre=Action 处理。
func TestListMediaAcceptsBracketGenreParam(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "动作", Genres: "Action", Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "喜剧", Genres: "Comedy", Path: "/b.mkv"},
)
// genre[]=Action — axios bracket format without custom paramsSerializer
code, body := getJSON(t, router, "/api/libraries/"+libID+"/media?group_versions=0&genre[]=Action")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var payload struct {
Items []model.Media `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode %s: %v", body, err)
}
if payload.Total != 1 || len(payload.Items) != 1 || payload.Items[0].Title != "动作" {
t.Fatalf("payload = %+v, want only 动作 for genre[]=Action", payload)
}
}
// 随机也遵守筛选:只命中 Action 时,带 Comedy 筛选必须 404。
func TestLibraryRandomHonoursFilters(t *testing.T) {
router, svc, _, libID := newLibraryFilterEnv(t)
seedLibraryMedia(t, svc,
&model.Media{LibraryID: libID, Title: "A", Genres: "Action", Year: 2001, Path: "/a.mkv"},
&model.Media{LibraryID: libID, Title: "B", Genres: "Comedy", Year: 2002, Path: "/b.mkv"},
)
code, body := getJSON(t, router, "/api/libraries/"+libID+"/random?genre=Comedy&year_min=2002")
if code != http.StatusOK {
t.Fatalf("status = %d body=%s", code, body)
}
var media model.Media
if err := json.Unmarshal(body, &media); err != nil {
t.Fatal(err)
}
if media.Title != "B" {
t.Fatalf("title = %q, want B", media.Title)
}
}
+387 -120
View File
@@ -4,7 +4,11 @@ package handler
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -37,8 +41,32 @@ type webLibraryPayload struct {
RemoteSource string `json:"remote_source,omitempty"`
Total int64 `json:"total,omitempty"`
Cards []service.SeriesCardView `json:"cards,omitempty"`
// CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的
// cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都
// 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。
CoverImageURL string `json:"cover_image_url,omitempty"`
}
// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游
// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。
func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string {
if isRemote {
return ""
}
if raw := strings.TrimSpace(lib.CoverURL); raw != "" {
return raw
}
if strings.TrimSpace(lib.ID) == "" {
return ""
}
return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover"
}
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
var remoteLibraryPreviewBudget = 3 * time.Second
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
@@ -116,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
for _, p := range previews {
out = append(out, webLibraryPayload{
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
Library: p.Library,
Total: p.Total,
Cards: service.NewSeriesCardViews(p.Cards),
CoverImageURL: effectiveLibraryCoverURL(p.Library, false),
})
}
} else {
@@ -137,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
if counts != nil {
total = counts[l.ID]
}
out = append(out, webLibraryPayload{Library: l, Total: total})
out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)})
}
}
// 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。
@@ -175,6 +204,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
defer cancelPreview()
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
@@ -186,11 +221,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
case <-previewCtx.Done():
return
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
if acct == nil {
return
}
@@ -202,10 +237,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
@@ -258,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]})
c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)})
} else {
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib})
c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)})
}
}
}
@@ -407,6 +442,92 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
}
}
// parseLibraryFilters 解析媒体库列表的筛选查询参数。
//
// 全部参数都是可选的:缺省时返回零值,`MediaListFilters.empty()` 为真,列表
// 行为与此前完全一致(不引入任何默认筛选)。
//
// 参数约定:
// - genre=Action&genre=Comedy 类型多选(或关系,整词匹配)
// - year_min / year_max 年份区间,0 或非法值表示不限
// - rating_min 评分下限(浮点)
// - unwatched=1 仅显示未看完;用户 ID 取自会话
func parseLibraryFilters(c *gin.Context) service.MediaListFilters {
filters := service.MediaListFilters{
Genres: parseRepeatedQueryValues(c, "genre"),
YearMin: parseNonNegativeInt(firstQueryValue(c, "year_min", "yearMin")),
YearMax: parseNonNegativeInt(firstQueryValue(c, "year_max", "yearMax")),
RatingMin: parseNonNegativeFloat(firstQueryValue(c, "rating_min", "ratingMin")),
}
if isTruthyQuery(firstQueryValue(c, "unwatched", "unwatched_only", "unwatchedOnly")) {
filters.Unwatched = true
filters.UserID = toString(mustSessionUserID(c))
}
return filters
}
// parseRepeatedQueryValues 读取可重复出现的查询参数,去重并丢弃空值。
// 同时接受 key[] 括号格式(axios 1.x 默认序列化方式)作为向后兼容回退,
// 在前端 paramsSerializer 未正确配置时不会静默返回空结果。
func parseRepeatedQueryValues(c *gin.Context, key string) []string {
raw := c.QueryArray(key)
if len(raw) == 0 {
// fallback: axios bracket format (e.g. genre[]=Action&genre[]=Comedy)
raw = c.QueryArray(key + "[]")
}
if len(raw) == 0 {
return nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, value := range raw {
// 客户端可能把多值拼成一次逗号分隔,两种形式都要接受。
for _, part := range strings.Split(value, ",") {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
}
return out
}
func parseNonNegativeInt(raw string) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 0 {
return 0
}
return value
}
func parseNonNegativeFloat(raw string) float64 {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || value < 0 || math.IsNaN(value) {
return 0
}
return value
}
func isTruthyQuery(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// mustSessionUserID 取会话用户 ID,缺失时返回空串(筛选逻辑会忽略它)。
func mustSessionUserID(c *gin.Context) any {
uid, _ := c.Get(middleware.CtxUserID)
return uid
}
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
@@ -450,9 +571,10 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
if sortSpec.Field == "last_played" {
history = mediaHistoryMap(c, svc)
}
filters := parseLibraryFilters(c)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
if !groupVersions {
items, total, err := svc.Media.ListMediaVisible(ctx, id, page, size, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisibleFiltered(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters)
if err != nil {
writeInternalOrCanceled(c, err)
return
@@ -468,19 +590,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
})
return
}
grouped, err := svc.Media.GroupedMediaVisible(ctx, id, mediaVisibilityForRequest(c, svc))
items, total, err := svc.Media.ListMediaVisibleSortedPage(ctx, id, page, size, mediaVisibilityForRequest(c, svc), filters, sortSpec, history)
if err != nil {
writeInternalOrCanceled(c, err)
return
}
grouped = service.SortMediaItems(grouped, sortSpec.Field, sortSpec.Order, history)
items := service.PaginateMediaItems(grouped, page, size)
if items == nil {
items = []service.MediaItem{}
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": len(grouped),
"total": total,
"page": page,
"page_size": size,
})
@@ -568,6 +688,9 @@ func paginateSlice[T any](items []T, page, size int) []T {
if len(items) == 0 {
return []T{}
}
if page-1 > len(items)/size {
return []T{}
}
start := (page - 1) * size
if start >= len(items) {
return []T{}
@@ -582,127 +705,257 @@ func paginateSlice[T any](items []T, page, size int) []T {
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
q := c.Query("q")
query := strings.TrimSpace(c.Query("q"))
visibility := mediaVisibilityForRequest(c, svc)
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
fetchRemote := func(limit int) []model.Media {
if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" {
return nil
}
remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility)
return remoteItems
filter := service.MediaSearchFilter{
LibraryID: strings.TrimSpace(c.Query("library_id")),
YearMin: boundedSearchYear(c.Query("year_min")),
YearMax: boundedSearchYear(c.Query("year_max")),
}
if c.DefaultQuery("group_series", "0") != "0" {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
for _, genre := range c.QueryArray("genre") {
if genre = strings.TrimSpace(genre); genre != "" && len(filter.Genres) < 8 {
filter.Genres = append(filter.Genres, genre)
}
remoteItems := fetchRemote(50000)
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
limit = 50
}
if len(all) > limit {
all = all[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
if c.Query("page") != "" || c.Query("page_size") != "" {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if !groupVersions {
localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
paged := paginateSlice(all, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(all),
"page": page,
"page_size": size,
})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(size * 2)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
paged := service.PaginateMediaItems(grouped, page, size)
c.JSON(http.StatusOK, gin.H{
"items": paged,
"total": len(grouped),
"page": page,
"page_size": size,
})
return
if filter.YearMin > 0 && filter.YearMax > 0 && filter.YearMin > filter.YearMax {
filter.YearMin, filter.YearMax = filter.YearMax, filter.YearMin
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if size < 1 {
size = 50
} else if size > 2000 {
size = 2000
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
if limit <= 0 {
if limit < 1 {
limit = 50
} else if limit > 50000 {
limit = 50000
}
if !groupVersions {
localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
if len(all) > limit {
all = all[:limit]
}
if all == nil {
all = []model.Media{}
}
c.JSON(http.StatusOK, gin.H{"items": all})
return
}
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
remoteItems := fetchRemote(limit)
all := append(localItems, remoteItems...)
grouped := service.GroupMediaVersions(all)
items := service.FirstMediaItems(grouped, limit)
if items == nil {
items = []service.MediaItem{}
if remoteCh != nil {
for _, item := range <-remoteCh {
items = append(items, item)
}
}
c.JSON(http.StatusOK, gin.H{"items": items})
if c.DefaultQuery("group_series", "0") != "0" {
items = service.GroupMediaSeriesItems(items)
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
return
}
if c.DefaultQuery("group_versions", "1") != "0" {
grouped := service.GroupMediaVersions(items)
if sortBy != "" {
sort.SliceStable(grouped, func(i, j int) bool {
return mediaSearchSortLess(grouped[i].Media, grouped[j].Media, sortBy)
})
}
writeMediaSearchResults(c, grouped, page, size, limit, paginated)
return
}
sortSearchMedia(items, sortBy)
writeMediaSearchResults(c, items, page, size, limit, paginated)
}
}
const (
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
remoteSearchItemLimit = 100
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
remoteSearchBudget = 2 * time.Second
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
// 一页都重新打一遍远程挂载点。
remoteSearchCacheTTL = 30 * time.Second
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
remoteSearchFailureCacheTTL = 5 * time.Second
)
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
func startRemoteMediaSearch(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) <-chan []model.Media {
if svc == nil || svc.EmbyRemote == nil || query == "" {
return nil
}
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
return nil
}
results := make(chan []model.Media, 1)
go func() {
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
}()
return results
}
func remoteMediaSearchItems(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) []model.Media {
cacheKey := remoteSearchCacheKey(query, visibility)
if svc.Cache != nil {
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
if rows, ok := cached.([]model.Media); ok {
return filterRemoteSearchItems(rows, filter)
}
}
}
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
defer cancel()
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
if svc.Cache != nil {
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
if remote == nil {
remote = []model.Media{}
}
ttl := remoteSearchCacheTTL
if searchCtx.Err() != nil {
ttl = remoteSearchFailureCacheTTL
}
svc.Cache.SetObject(cacheKey, remote, ttl)
}
return filterRemoteSearchItems(remote, filter)
}
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
if len(remote) == 0 {
return nil
}
out := make([]model.Media, 0, len(remote))
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
out = append(out, item)
}
}
return out
}
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
// 点的结果复用给可见范围不同的用户。
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
items = []T{}
}
if paginated {
c.JSON(http.StatusOK, gin.H{
"items": paginateSlice(items, page, size),
"total": len(items), "page": page, "page_size": size,
})
return
}
if len(items) > limit {
items = items[:limit]
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func remoteSearchMatchesFilter(item model.Media, filter service.MediaSearchFilter) bool {
if filter.LibraryID != "" && item.LibraryID != filter.LibraryID && item.DisplayLibraryID != filter.LibraryID {
return false
}
if filter.YearMin > 0 && item.Year < filter.YearMin || filter.YearMax > 0 && item.Year > filter.YearMax {
return false
}
if len(filter.Genres) == 0 {
return true
}
normalize := func(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))
}
for _, genre := range strings.Split(strings.ReplaceAll(item.Genres, ",", ","), ",") {
for _, wanted := range filter.Genres {
if normalize(genre) == normalize(wanted) {
return true
}
}
}
return false
}
func normalizeMediaSearchSort(value string) string {
value = strings.TrimSpace(value)
switch value {
case "title_asc", "year_desc", "rating_desc":
return value
default:
return ""
}
}
func sortSearchMedia(items []model.Media, sortBy string) {
if sortBy == "" {
return
}
sort.SliceStable(items, func(i, j int) bool {
return mediaSearchSortLess(items[i], items[j], sortBy)
})
}
func mediaSearchSortLess(a, b model.Media, sortBy string) bool {
switch sortBy {
case "title_asc":
left, right := strings.ToLower(strings.TrimSpace(a.Title)), strings.ToLower(strings.TrimSpace(b.Title))
if left != right {
return left < right
}
case "year_desc":
if a.Year != b.Year {
return a.Year > b.Year
}
case "rating_desc":
if a.Rating != b.Rating {
return a.Rating > b.Rating
}
}
if !a.CreatedAt.Equal(b.CreatedAt) {
return a.CreatedAt.After(b.CreatedAt)
}
return a.ID < b.ID
}
func boundedSearchYear(value string) int {
year, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || year < 1870 || year > 2200 {
return 0
}
return year
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
@@ -722,6 +975,20 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
// 原有的 token / proxy 查询参数一并保留。
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
target += "?" + rawQuery
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, target)
return
}
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
// 与挂载账号的 proxy_play 开关等价。
if mount.ProxyPlay || wantSameOriginProxy(c) {
@@ -0,0 +1,136 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
oldBudget := remoteLibraryPreviewBudget
remoteLibraryPreviewBudget = 200 * time.Millisecond
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/Views"):
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
}},
})
return
case strings.HasSuffix(path, "/Users"):
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
return
case r.Method == http.MethodPost:
_ = json.NewEncoder(w).Encode(map[string]any{
"AccessToken": "fake-token",
"User": map[string]any{"Id": "uid-1"},
})
return
}
select {
case <-release:
case <-r.Context().Done():
case <-time.After(30 * time.Second):
}
w.WriteHeader(http.StatusGatewayTimeout)
}))
defer upstream.Close()
defer close(release)
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
start := time.Now()
listLibrariesHandler(svc)(c)
elapsed := time.Since(start)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if elapsed > 2*time.Second {
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
}
var payload []webLibraryPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
}
var remote *webLibraryPayload
for i := range payload {
if payload[i].IsRemoteEmby {
remote = &payload[i]
break
}
}
if remote == nil {
t.Fatalf("remote library missing from the bounded response: %#v", payload)
}
if len(remote.Cards) != 0 || remote.Total != 0 {
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
}
}
+37
View File
@@ -0,0 +1,37 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaQualityHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
if page < 1 {
page = 1
} else if page > 1000000 {
page = 1000000
}
if pageSize < 1 {
pageSize = 50
} else if pageSize > 100 {
pageSize = 100
}
issue := c.DefaultQuery("issue", "unmatched")
if issue != "unmatched" && issue != "poster" && issue != "missing" {
c.JSON(http.StatusBadRequest, gin.H{"error": "未知检查分类"})
return
}
items, total, err := svc.Media.QualityIssues(c.Request.Context(), issue, page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
}
}
@@ -0,0 +1,88 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSortLimitFiltersAndDeepPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "Review", Path: "/review", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := make([]model.Media, 2001)
for i := range rows {
rows[i] = model.Media{
Base: model.Base{ID: fmt.Sprintf("review-%04d", i)}, LibraryID: lib.ID,
Title: fmt.Sprintf("Review %04d", i), Path: fmt.Sprintf("/review/%04d.mp4", i), Year: 2020, Genres: "Comedy",
}
}
rows[2000].Year = 2025
rows[2000].Genres = "Drama"
if err := db.CreateInBatches(rows, 100).Error; err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos, Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos)}
cases := []struct {
name, query, expectedID string
count, total, page, size int
}{
{name: "sorted output limit", query: "sort=year_desc&limit=1", expectedID: "review-2000", count: 1},
{name: "page past old 2000 cap", query: "sort=title_asc&page=2001&page_size=1", expectedID: "review-2000", count: 1, total: 2001, page: 2001, size: 1},
{name: "genre filtering", query: "genre=Drama&library_id=" + lib.ID, expectedID: "review-2000", count: 1},
{name: "normalized page", query: "page=0&page_size=0", count: 50, total: 2001, page: 1, size: 50},
{name: "bounded page size", query: "page=1&page_size=99999", count: 2000, total: 2001, page: 1, size: 2000},
{name: "huge page does not overflow", query: "page=" + strconv.Itoa(int(^uint(0)>>1)) + "&page_size=2000", count: 0, total: 2001, page: int(^uint(0) >> 1), size: 2000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(w)
ctx.Set(middleware.CtxUserID, "admin")
ctx.Set(middleware.CtxUserRole, "admin")
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=Review&group_versions=0&"+tc.query, nil)
searchMediaHandler(svc)(ctx)
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &response) != nil {
t.Fatalf("search failed: status=%d body=%s", w.Code, w.Body.String())
}
if len(response.Items) != tc.count || response.Total != tc.total || response.Page != tc.page || response.Size != tc.size {
t.Fatalf("response count=%d total=%d page=%d size=%d, want %#v", len(response.Items), response.Total, response.Page, response.Size, tc)
}
if tc.expectedID != "" && response.Items[0].ID != tc.expectedID {
t.Fatalf("first ID=%q want %q", response.Items[0].ID, tc.expectedID)
}
})
}
}
@@ -0,0 +1,153 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
// 3. 指定本地媒体库时不应枚举远程挂载点。
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
gin.SetMode(gin.TestMode)
var mu sync.Mutex
searchCalls := 0
searchLimits := make([]string, 0, 4)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if term := r.URL.Query().Get("SearchTerm"); term != "" {
mu.Lock()
searchCalls++
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "remote-1",
"Name": "远方剧集",
"Type": "Series",
"ProductionYear": 2024,
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程剧集", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
search := func(params string) []service.MediaItem {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items
}
callCount := func() int {
mu.Lock()
defer mu.Unlock()
return searchCalls
}
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("expected remote result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected exactly one upstream search, got %d", calls)
}
mu.Lock()
limit := searchLimits[0]
mu.Unlock()
if limit != "100" {
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
}
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("cached search lost the remote result: %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
}
// 指定本地媒体库时不应枚举远程挂载点。
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
t.Fatalf("expected local-only result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
}
}
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "same-series", "Name": "龙珠改", "Type": "Series",
"ProviderIds": map[string]string{"Tmdb": "61709"},
"ProductionYear": 2024, "Genres": []string{"Science Fiction"},
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
rows := []model.Media{
{Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709},
{Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
expectedIDs := map[string]string{lib.ID: "local-1"}
for _, suffix := range []string{"a", "b"} {
mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series")
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
}
request := func(params string) ([]model.Media, int) {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []model.Media `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items, response.Total
}
items, _ := request("limit=8")
if len(items) != len(expectedIDs) {
t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items)
}
seen := make(map[string]bool)
for page := 1; page <= len(expectedIDs); page++ {
items, total := request(fmt.Sprintf("page=%d&page_size=1", page))
if total != len(expectedIDs) || len(items) != 1 {
t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items)
}
item := items[0]
if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" {
t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item)
}
seen[item.DisplayLibraryID] = true
}
remoteLibraryID := service.EncodeEmbyRemoteID("mount-a", "view-a")
items, total := request("page=1&page_size=8&library_id=" + url.QueryEscape(remoteLibraryID) + "&year_min=2020&genre=Science+Fiction")
if total != 1 || len(items) != 1 || items[0].DisplayLibraryID != remoteLibraryID {
t.Fatalf("remote library/year/genre filters lost results: total=%d items=%#v", total, items)
}
}
+7 -2
View File
@@ -26,7 +26,9 @@ import (
)
func TestQueueLibraryRootScanImportsNewLibraryMedia(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// The scanner and polling loop use separate connections; :memory: gives
// each connection its own empty database.
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "scan.db")+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
@@ -557,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}

Some files were not shown because too many files have changed in this diff Show More