Compare commits

...

213 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
truewhile 61a7260eaf 优化,现有bug处理 2026-09-18 21:55:12 +08:00
truewhile 45f4763b0c 添加metatube部署文件 2026-09-18 12:00:04 +08:00
truewhile f4f57759ce 优化移动端播放 2026-09-18 11:28:33 +08:00
truewhile 6ec9adc976 优化移动端播放 2026-09-18 10:09:08 +08:00
truewhile f4abdf78e2 优化在线字幕 2026-09-17 23:53:22 +08:00
truewhile ae8db26c1e bug处理 2026-09-17 23:20:58 +08:00
truewhile 8017e994f4 优化 2026-09-17 22:57:41 +08:00
truewhile d56fb53a08 优化界面,优化多版本切换逻辑 2026-09-17 22:33:53 +08:00
truewhile 30ca94c79b 优化问题 2026-09-17 14:17:09 +08:00
truewhile 9c1f9a6e7e bug处理 2026-09-17 11:19:38 +08:00
truewhile ec7be38266 优化vr播放逻辑 2026-09-17 09:22:00 +08:00
truewhile ebf791c436 优化 2026-09-17 01:29:10 +08:00
truewhile 83c0b65a0b 优化360播放 2026-09-17 01:08:28 +08:00
truewhile 1153fe4191 添加VR360播放功能 2026-09-17 00:31:45 +08:00
truewhile 93866d169b 优化 2026-09-16 23:59:38 +08:00
truewhile d283d39008 优化 2026-09-16 23:36:03 +08:00
truewhile fc89d23867 优化 2026-09-16 22:20:23 +08:00
truewhile 1f14a1e85b 优化 2026-09-16 21:44:42 +08:00
truewhile f0a6b630c6 优化 2026-09-16 21:19:46 +08:00
truewhile c723664e46 优化 2026-09-16 18:34:24 +08:00
truewhile 5faa6975d0 优化标签 2026-09-16 13:50:06 +08:00
truewhile cbfae0252e t添加标签功能 2026-09-16 12:30:34 +08:00
truewhile 57ee482146 优化 2026-09-15 23:13:31 +08:00
truewhile a9b3110b2c 优化 2026-09-15 22:05:34 +08:00
truewhile ff86c2f597 yb显示 2026-09-15 18:50:50 +08:00
truewhile a5731fa963 优化显示 2026-09-15 18:26:20 +08:00
truewhile 531e45f677 优化 2026-09-15 16:48:42 +08:00
truewhile cd3f028f0b 修复bug 2026-09-15 16:12:59 +08:00
truewhile bd7b7a722b Merge pull request #34 from truewhile/cursor/hide-viewer-admin-ui
Cursor/hide viewer admin UI
2026-09-15 15:23:58 +08:00
truewhile 83deffcf45 修复 2026-09-15 15:21:43 +08:00
truewhile c74f6be250 Hide scrape and library admin controls from non-admin users.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 15:20:09 +08:00
truewhile c911a57467 bug处理 2026-09-15 09:58:47 +08:00
truewhile 1dbc3f8fe7 Merge branch 'main' of https://github.com/truewhile/MeBox 2026-09-15 00:00:54 +08:00
truewhile a563ee11d6 优化,添加滚动记忆 2026-09-15 00:00:35 +08:00
truewhile 215ebf6942 Merge pull request #33 from truewhile/cursor/fix-library-scrape-dialog-position
优化
2026-09-14 22:55:16 +08:00
truewhile e4c0e85eaa 优化 2026-09-14 22:54:09 +08:00
truewhile 60f05129c9 优化 2026-09-14 21:56:47 +08:00
truewhile fa4c90d47a bug处理 2026-09-14 21:40:13 +08:00
truewhile 1ec8303fec bug处理 2026-09-14 21:16:34 +08:00
truewhile b28c0d9247 添加115云端解码功能 2026-09-14 17:57:51 +08:00
truewhile 91c6d9dfd9 处理token刷新失败bug 2026-09-14 16:47:44 +08:00
truewhile 95d8126d65 优化windows包,优化windos可执行文件功能 2026-09-14 15:48:59 +08:00
truewhile 3aa8662c45 优化,日志脱敏 2026-09-14 15:12:42 +08:00
truewhile 5c85478883 1 2026-09-14 12:33:59 +08:00
truewhile fd54d42db7 优化 2026-09-14 00:02:46 +08:00
truewhile 4595b7c5be 优化 2026-09-13 23:45:21 +08:00
truewhile ee3927cdf1 优化 2026-09-13 23:39:07 +08:00
truewhile 4b34fc4616 优化 2026-09-13 23:11:28 +08:00
truewhile b7f1cd85f8 支持按用户保存播放器与弹幕偏好
在 user 记录上新增播放器音量、弹幕开关、透明度、字号、显示区域、合并偏好、自定义弹幕源以及 dandanplay 应用凭据字段,并提供 PUT /danmaku/settings 局部更新接口,带取值校验。
返回脱敏后的完整渲染配置,绝不回传应用密钥,仅在响应中标记密钥是否已配置。
结果缓存键加入用户 ID 与凭据指纹,避免不同用户或凭据串用缓存;旧版实例级设置仅作为未登录与迁移期的回退。
2026-09-13 23:01:43 +08:00
truewhile be9d3a6d9e 优化 2026-09-13 22:00:50 +08:00
truewhile b8e48a1f45 优化 2026-09-13 21:23:06 +08:00
truewhile 670dd90dbe 优化 2026-09-13 21:03:06 +08:00
truewhile 9a68ef701f 重构核心模块并同步更新项目配置与相关实现 2026-09-13 20:36:14 +08:00
truewhile 386fc358d1 Add configurable memory limits and LRU eviction for runtime cache 2026-09-13 00:41:04 +08:00
truewhile 2989649445 Add Emby-compatible request logging and ordered playback progress 2026-09-12 23:06:44 +08:00
truewhile 5ddca9a1f6 支持 STRM 播放端点的 HEAD 请求 2026-09-12 22:31:30 +08:00
truewhile e337d9932b Fix Emby person image routing and search dropdown dismissal 2026-09-12 16:55:53 +08:00
truewhile df654ea47b Add TMDb-backed Emby people metadata and image caching 2026-09-12 16:31:57 +08:00
truewhile 41fe75e136 支持 Emby 演职人员头像代理并优化媒体系列搜索 2026-09-12 16:07:16 +08:00
truewhile e65accf2cd Optimize Emby caching and image resize concurrency 2026-09-12 12:46:30 +08:00
truewhile f54ef2228c Merge pull request #32 from truewhile/cursor/fix-emby-hero-backdrop
修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
2026-09-12 11:47:38 +08:00
truewhile 3c2bfb14ca 修复 Emby 大海报在剧集图缓存被淘汰后显示占位图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:43:51 +08:00
truewhile 8f64f961ac 缓存弹幕抓取结果并合并并发请求 2026-09-12 11:02:24 +08:00
truewhile 6797394c5b Serve font assets with caching and test missing-font handling 2026-09-12 10:33:51 +08:00
truewhile e71d9d61d1 优先使用刮削元数据匹配弹幕剧集 2026-09-12 10:27:55 +08:00
truewhile 1ff60d5641 完善应用功能与界面交互 2026-09-12 01:00:19 +08:00
truewhile 3f361631d0 修复搜索下拉框异步响应导致的意外展开 2026-09-11 21:30:30 +08:00
truewhile cfe0c0cf28 Preserve media metadata across STRM path renames 2026-09-11 20:45:54 +08:00
truewhile d6355d5582 Keep TV episodes as mixed-series representatives 2026-09-11 19:52:49 +08:00
truewhile 0f06bdf929 支持 ASS 字幕原样渲染与字幕显示设置 2026-09-11 19:35:08 +08:00
truewhile e840cbd2c9 处理bug 2026-09-11 15:35:02 +08:00
truewhile 2bb3610da7 添加繁体转简体,简体转繁体功能 2026-09-11 15:14:24 +08:00
truewhile 9ca1b28cb4 优化 2026-09-10 22:30:17 +08:00
truewhile 9effb1422b 优化 2026-09-10 22:06:28 +08:00
truewhile b855e00345 优化 2026-09-10 16:58:41 +08:00
truewhile 8f2551a1b6 优化排序 2026-09-09 22:24:45 +08:00
truewhile 18e6cb40fd 完善动漫特别内容分类与版本判定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 22:03:52 +08:00
truewhile 92e693ccac 优化 2026-09-09 20:32:03 +08:00
truewhile 62206aa05a 优化 2026-09-09 19:26:22 +08:00
truewhile f9dd082159 优化动漫刮削 2026-09-09 17:36:11 +08:00
866 changed files with 123496 additions and 6400 deletions
+1
View File
@@ -14,6 +14,7 @@ verify-cache/
verify-media/
verify-downloads/
.codex-*
.codex/
.tmp/
.tmp_*
.tmp-deploy-*
+7 -3
View File
@@ -115,7 +115,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
@@ -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
@@ -196,8 +196,12 @@ jobs:
path: web/dist
- name: Build binary
run: |
LDFLAGS="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}"
if [ "${{ matrix.goos }}" = "windows" ]; then
LDFLAGS="$LDFLAGS -H=windowsgui"
fi
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
go build -trimpath -ldflags="-s -w -X main.version=${{ needs.build-image.outputs.release_tag }}" \
go build -trimpath -ldflags="$LDFLAGS" \
-o "dist/mebox-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}" ./cmd/server
- name: Package
run: |
+35 -28
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,23 +31,30 @@ 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.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -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 -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
cache-to: type=gha,mode=max
+37 -4
View File
@@ -15,14 +15,14 @@ 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
# before the Go toolchain touches the `web` package.
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build SPA
@@ -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
@@ -51,7 +84,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: web/package-lock.json
@@ -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"
+3 -1
View File
@@ -66,6 +66,7 @@ config.yaml
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
@@ -79,4 +80,5 @@ media/
tools/
verify-cache/
verify-data/
.zcode/
.zcode/
.tmp-src
+1
View File
@@ -0,0 +1 @@
20.19.0
+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`。
+10 -7
View File
@@ -2,8 +2,8 @@
# =============================================================================
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
@@ -15,7 +15,7 @@
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
FROM --platform=$BUILDPLATFORM node:20.19-alpine AS frontend
ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/
WORKDIR /app/web
COPY web/package*.json ./
@@ -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"]
+15 -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" />
@@ -199,6 +199,11 @@ environment:
**硬链接失败(cross-device link)?**
硬链接要求源与目标在同一文件系统/子卷;跨盘、跨 btrfs 子卷或网盘挂载时请改用复制或软链接。
**日志保留时间太短?**
默认应用日志为 `20MB x 5`,容器 stdout 日志为 `20m x 3`。排障时可在 compose 中调大
`MEBOX_LOGGING_MAX_SIZE_MB`、`MEBOX_LOGGING_MAX_BACKUPS` 与服务的 `logging.options.max-size/max-file`。
**第三方播放器连不上?**
确认地址为 `http://IP:18080`,使用 MeBox 用户账号;反代部署需正确配置外部 URL 与 HTTPS 头。
@@ -207,6 +212,7 @@ environment:
## 开发构建
后端通过 `go:embed` 嵌入 `web/dist`,**编译前必须先构建前端**。
前端构建要求 Node.js `20.19+` 或 `22.12+`。
```bash
npm --prefix web ci
@@ -219,6 +225,14 @@ npm --prefix web run dev # http://127.0.0.1:3000
CI 会在 Release 中提供 Windows / Linux / macOS 的 amd64、arm64 单文件可执行程序。
Windows 本地打包:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
Windows 可执行程序使用项目 Logo,不显示控制台窗口;启动后会常驻系统托盘。托盘菜单可打开 MeBox、切换开机自启、查看日志、重启或退出。
---
## 鸣谢
+9 -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" />
@@ -199,6 +199,14 @@ npm --prefix web run dev
Release builds ship single-file binaries for Windows, Linux, and macOS on amd64 and arm64.
Build the Windows executable locally:
```powershell
.\scripts\build-windows.ps1 -Version dev
```
The Windows executable uses the project logo and runs without a console window. It stays in the notification area, with menu actions for opening MeBox, toggling auto-start, viewing logs, restarting, and exiting.
---
## Acknowledgements
+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)
}
+208
View File
@@ -0,0 +1,208 @@
package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// application owns the long-running MeBox server and all resources created
// during startup. Platform entry points decide how the application is
// controlled: a console signal loop or a Windows notification-area icon.
type application struct {
cfg *config.Config
logger *zap.Logger
embyCompatLogger *zap.Logger
serverManager *serverManager
services *service.Container
closeMu sync.Mutex
closeFuncs []func()
shutdown sync.Once
shutdownErr error
}
func newApplication() (*application, error) {
cfg, err := config.Load()
if err != nil {
return nil, fmt.Errorf("config load failed: %w", err)
}
logger, closeLogger, err := newLoggerWithCloser(cfg)
if err != nil {
return nil, fmt.Errorf("logger init failed: %w", err)
}
app := &application{
cfg: cfg,
logger: logger,
closeFuncs: []func(){closeLogger},
}
if err := app.start(); err != nil {
app.closeLoggers()
return nil, err
}
return app, nil
}
func (a *application) start() error {
embyCompatLogger, closeEmbyCompatLogger, err := newEmbyCompatLogger(a.cfg)
if err != nil {
a.logger.Error("Emby compatibility logger init failed", zap.Error(err))
return fmt.Errorf("Emby compatibility logger init failed: %w", err)
}
a.embyCompatLogger = embyCompatLogger
a.addCloser(closeEmbyCompatLogger)
appVersion := effectiveVersion(version)
a.logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", a.cfg.App.Port),
zap.String("data_dir", a.cfg.App.DataDir),
zap.String("emby_compat_log", embyCompatLogPath(a.cfg)),
)
for _, dir := range []string{a.cfg.App.DataDir, a.cfg.Cache.CacheDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
a.logger.Error("create dir failed", zap.String("dir", dir), zap.Error(err))
return fmt.Errorf("create dir %s failed: %w", dir, err)
}
}
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))
return fmt.Errorf("database open failed: %w", err)
}
if err := waitForDatabase(db, a.logger); err != nil {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
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)
}
if err := database.MigrateSQLiteToCurrentIfNeeded(a.cfg, db, a.logger); err != nil {
a.logger.Error("sqlite to postgres migration failed", zap.Error(err))
return fmt.Errorf("sqlite to postgres migration failed: %w", err)
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), a.cfg, repos, a.logger)
applyCPUThreadLimit(a.cfg, a.logger)
a.services = service.NewWithVersion(a.cfg, a.logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := a.services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
a.logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
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))
}
router := buildRouter(a.cfg, a.logger, a.embyCompatLogger, a.services)
a.serverManager = newServerManager(a.cfg, a.logger, router)
a.services.ReloadHTTPServer = a.serverManager.Reload
if err := a.serverManager.Start(); err != nil {
a.logger.Error("listen failed", zap.Error(err))
return fmt.Errorf("listen failed: %w", err)
}
go func() {
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
a.logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, a.cfg.App.Port)),
)
}
}()
helper.Go(a.logger, "services.boot", a.services.Boot)
return nil
}
// Shutdown is idempotent and safe to call from both the tray handler and the
// systray exit callback.
func (a *application) Shutdown() error {
a.shutdown.Do(func() {
a.logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
if a.serverManager != nil {
if err := a.serverManager.Shutdown(ctx); err != nil {
a.shutdownErr = err
a.logger.Error("graceful shutdown failed", zap.Error(err))
}
}
cancel()
if a.services != nil {
a.services.Close()
}
a.logger.Info("MeBox stopped")
_ = a.logger.Sync()
if a.embyCompatLogger != nil {
_ = a.embyCompatLogger.Sync()
}
a.closeLoggers()
})
return a.shutdownErr
}
func (a *application) addCloser(fn func()) {
if fn == nil {
return
}
a.closeMu.Lock()
a.closeFuncs = append(a.closeFuncs, fn)
a.closeMu.Unlock()
}
func (a *application) closeLoggers() {
a.closeMu.Lock()
closers := append([]func(){}, a.closeFuncs...)
a.closeFuncs = nil
a.closeMu.Unlock()
for i := len(closers) - 1; i >= 0; i-- {
closers[i]()
}
}
func (a *application) localURL() string {
config.RuntimeMu.RLock()
scheme := "http"
if a.cfg.App.HTTPSEnabled {
scheme = "https"
}
port := a.cfg.App.Port
config.RuntimeMu.RUnlock()
return fmt.Sprintf("%s://127.0.0.1:%d", scheme, port)
}
+38
View File
@@ -98,3 +98,41 @@ func logFilePaths(cfg *config.Config) (string, string, string) {
}
return filepath.Join(out, "app.log"), filepath.Join(out, "warn.log"), filepath.Join(out, "error.log")
}
// newEmbyCompatLogger 构建只写入 Emby 兼容日志文件的独立 Zap 实例。
// 它不参与 app.log 的日志级别过滤,始终记录 INFO 及以上,确保成功请求也能
// 用于还原客户端的接口调用顺序;轮转参数沿用 logging 配置。
func newEmbyCompatLogger(cfg *config.Config) (*zap.Logger, func(), error) {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder
var encoder zapcore.Encoder
if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") {
encoder = zapcore.NewConsoleEncoder(encoderCfg)
} else {
encoder = zapcore.NewJSONEncoder(encoderCfg)
}
writer, err := newRotatingFileWriter(embyCompatLogPath(cfg), cfg.Logging)
if err != nil {
return nil, nil, err
}
log := zap.New(
zapcore.NewCore(encoder, writer, zap.InfoLevel),
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
zap.ErrorOutput(zapcore.Lock(os.Stderr)),
)
return log, func() { _ = writer.Close() }, nil
}
func embyCompatLogPath(cfg *config.Config) string {
out := strings.TrimSpace(cfg.Logging.OutputPath)
if out == "" || strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") {
return filepath.Join(cfg.App.DataDir, "logs", "emby-compat.log")
}
if ext := filepath.Ext(out); ext != "" {
base := strings.TrimSuffix(out, ext)
return base + ".emby-compat" + ext
}
return filepath.Join(out, "emby-compat.log")
}
+31
View File
@@ -116,3 +116,34 @@ func TestRotatingFileWriterCapsFileSize(t *testing.T) {
}
_ = writer.Sync()
}
func TestEmbyCompatLoggerWritesDedicatedFile(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{}
cfg.App.DataDir = dir
cfg.Logging.Format = "json"
cfg.Logging.OutputPath = filepath.Join(dir, "logs")
cfg.Logging.EnableRotation = true
cfg.Logging.MaxSizeMB = 1
cfg.Logging.MaxBackups = 2
log, closeFn, err := newEmbyCompatLogger(cfg)
if err != nil {
t.Fatal(err)
}
log.Info("emby request", zap.String("client", "Infuse"))
_ = log.Sync()
closeFn()
path := filepath.Join(dir, "logs", "emby-compat.log")
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "emby request") || !strings.Contains(string(data), "Infuse") {
t.Fatalf("dedicated Emby log missing request data: %s", data)
}
if _, err := os.Stat(filepath.Join(dir, "logs", "app.log")); !os.IsNotExist(err) {
t.Fatalf("Emby logger must not write app.log, stat err=%v", err)
}
}
+1 -104
View File
@@ -11,21 +11,8 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
@@ -46,95 +33,5 @@ func effectiveVersion(buildVersion string) string {
}
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
os.Exit(1)
}
logger, err := newLogger(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
os.Exit(1)
}
defer func() { _ = logger.Sync() }()
appVersion := effectiveVersion(version)
logger.Info("starting MeBox",
zap.String("version", appVersion),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
)
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o750); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
db, err := database.Open(cfg, logger)
if err != nil {
logger.Fatal("database open failed", zap.Error(err))
}
if err := waitForDatabase(db, logger); err != nil {
logger.Fatal("database not ready", zap.Error(err))
}
if err := database.AutoMigrate(db); err != nil {
logger.Fatal("auto-migrate failed", zap.Error(err))
}
if err := database.MigrateSQLiteToCurrentIfNeeded(cfg, db, logger); err != nil {
logger.Fatal("sqlite to postgres migration failed", zap.Error(err))
}
repos := repository.New(db)
service.ApplyRuntimeSettings(context.Background(), cfg, repos, logger)
applyCPUThreadLimit(cfg, logger)
services := service.NewWithVersion(cfg, logger, repos, appVersion)
// 一次性清洗历史脏数据: 老版本把单集 episode id / 单集名写进整剧字段, 导致
// 同一部剧被拆成多张单集卡。清空被污染的字段并重置为 pending(借后续重刮修正)。
if cleaned, err := services.NormalizePollutedEpisodeMetadata(context.Background()); err != nil {
logger.Warn("polluted episode metadata cleanup failed", zap.Error(err))
} else if cleaned > 0 {
logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned))
}
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, services)
serverMgr := newServerManager(cfg, logger, router)
services.ReloadHTTPServer = serverMgr.Reload
if err := serverMgr.Start(); err != nil {
logger.Fatal("listen failed", zap.Error(err))
}
go func() {
scheme := "http"
if cfg.App.HTTPSEnabled {
scheme = "https"
}
if publicIP := getPublicIP(3 * time.Second); publicIP != "" {
logger.Info("server public endpoint",
zap.String("public", fmt.Sprintf("%s://%s:%d", scheme, publicIP, cfg.App.Port)),
)
}
}()
helper.Go(logger, "services.boot", services.Boot)
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := serverMgr.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MeBox stopped")
runProgram()
}
+29
View File
@@ -76,6 +76,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
if err := os.MkdirAll(filepath.Join(webDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(webDir, "fonts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(webDir, "brand"), 0o755); err != nil {
t.Fatal(err)
}
@@ -85,6 +88,9 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
if err := os.WriteFile(filepath.Join(webDir, "assets", "app.js"), []byte("console.log('ok')"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "fonts", "geist-400.woff2"), []byte("wOF2-test-font"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(webDir, "brand", "mebox-logo.svg"), []byte("<svg></svg>"), 0o644); err != nil {
t.Fatal(err)
}
@@ -105,6 +111,29 @@ func TestServeSPAServesAssetsImmutableAndBypassesAPIRoutes(t *testing.T) {
t.Fatalf("asset Cache-Control = %q, want immutable", got)
}
fontReq := httptest.NewRequest(http.MethodGet, "/fonts/geist-400.woff2", nil)
fontResp := httptest.NewRecorder()
router.ServeHTTP(fontResp, fontReq)
if fontResp.Code != http.StatusOK {
t.Fatalf("font status = %d, want 200", fontResp.Code)
}
if got := fontResp.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=86400") {
t.Fatalf("font Cache-Control = %q, want max-age=86400", got)
}
if got := fontResp.Body.String(); got != "wOF2-test-font" {
t.Fatalf("font body = %q, want wOF2-test-font", got)
}
missingFontReq := httptest.NewRequest(http.MethodGet, "/fonts/missing.woff2", nil)
missingFontResp := httptest.NewRecorder()
router.ServeHTTP(missingFontResp, missingFontReq)
if missingFontResp.Code != http.StatusNotFound {
t.Fatalf("missing font status = %d, want 404", missingFontResp.Code)
}
if strings.Contains(missingFontResp.Body.String(), "index") {
t.Fatalf("missing font should not serve SPA index: %q", missingFontResp.Body.String())
}
brandReq := httptest.NewRequest(http.MethodGet, "/brand/mebox-logo.svg", nil)
brandResp := httptest.NewRecorder()
router.ServeHTTP(brandResp, brandReq)
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
func runProgram() {
app, err := newApplication()
if err != nil {
reportError("MeBox 启动失败", err)
os.Exit(1)
}
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
if err := app.Shutdown(); err != nil {
reportError("MeBox 退出失败", err)
}
}
func reportError(title string, err error) {
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "%s: %v\n", title, err)
}
+330
View File
@@ -0,0 +1,330 @@
//go:build windows
package main
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"fyne.io/systray"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"github.com/truewhile/MeBox/internal/brand"
)
const (
runRegistryKey = `Software\Microsoft\Windows\CurrentVersion\Run`
runRegistryValue = "MeBox"
singleInstanceName = `Local\MeBox-Server`
)
var errAlreadyRunning = errors.New("MeBox is already running")
func runProgram() {
if err := prepareWorkingDirectory(); err != nil {
reportError("MeBox 启动失败", fmt.Errorf("切换工作目录失败: %w", err))
return
}
instance, err := acquireSingleInstance()
if errors.Is(err, errAlreadyRunning) {
reportError("MeBox", errors.New("MeBox 已在运行,请查看右下角托盘图标"))
return
}
if err != nil {
reportError("MeBox 启动失败", fmt.Errorf("创建单实例锁失败: %w", err))
return
}
app, err := newApplication()
if err != nil {
_ = instance.Close()
reportError("MeBox 启动失败", err)
return
}
controller := &trayController{app: app}
systray.Run(controller.onReady, controller.onExit)
// Release the mutex before starting the replacement process. The new
// process must be able to acquire it immediately after the old one exits.
_ = instance.Close()
if !controller.readyClosed.Load() {
_ = app.Shutdown()
reportError("MeBox 启动失败", errors.New("系统托盘初始化失败"))
return
}
if controller.restartRequested.Load() {
if err := launchSelf(); err != nil {
reportError("MeBox 重启失败", err)
}
}
}
type trayController struct {
app *application
readyClosed atomic.Bool
restartRequested atomic.Bool
shutdownStarted atomic.Bool
}
func (c *trayController) onReady() {
defer func() {
c.readyClosed.Store(true)
}()
systray.SetIcon(brand.Icon)
systray.SetTooltip("MeBox")
mOpen := systray.AddMenuItem("打开 MeBox", "在浏览器中打开 MeBox")
mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 后自动启动 MeBox", autoStartEnabled())
mLogs := systray.AddMenuItem("查看日志", "打开 MeBox 应用日志")
systray.AddSeparator()
mRestart := systray.AddMenuItem("重启 MeBox", "重启 MeBox 服务")
mQuit := systray.AddMenuItem("退出 MeBox", "停止服务并退出")
initialAutoStart := mAutoStart.Checked()
go func() {
for {
select {
case <-mOpen.ClickedCh:
if err := openURL(c.app.localURL()); err != nil {
reportError("MeBox", fmt.Errorf("打开 MeBox 失败: %w", err))
}
case <-mAutoStart.ClickedCh:
enable := !mAutoStart.Checked()
if err := setAutoStart(enable); err != nil {
if initialAutoStart {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
reportError("MeBox", fmt.Errorf("更新开机自启设置失败: %w", err))
continue
}
if enable {
mAutoStart.Check()
} else {
mAutoStart.Uncheck()
}
initialAutoStart = enable
case <-mLogs.ClickedCh:
if err := c.app.openLog(); err != nil {
reportError("MeBox", fmt.Errorf("打开日志失败: %w", err))
}
case <-mRestart.ClickedCh:
c.restart()
case <-mQuit.ClickedCh:
c.quit()
}
}
}()
}
func (c *trayController) onExit() {
_ = c.app.Shutdown()
}
func (c *trayController) quit() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (c *trayController) restart() {
if !c.shutdownStarted.CompareAndSwap(false, true) {
return
}
c.restartRequested.Store(true)
go func() {
_ = c.app.Shutdown()
systray.Quit()
}()
}
func (a *application) openLog() error {
appLog, _, _ := logFilePaths(a.cfg)
if appLog != "" {
if _, err := os.Stat(appLog); err == nil {
return openPath(appLog)
}
_ = os.MkdirAll(filepath.Dir(appLog), 0o750)
return openPath(filepath.Dir(appLog))
}
logDir := filepath.Join(a.cfg.App.DataDir, "logs")
if err := os.MkdirAll(logDir, 0o750); err != nil {
return err
}
return openPath(logDir)
}
func prepareWorkingDirectory() error {
exe, err := os.Executable()
if err != nil {
return err
}
exeDir := filepath.Dir(exe)
cwd, err := os.Getwd()
if err != nil {
return err
}
if samePath(exeDir, cwd) || looksLikeProjectDirectory(cwd) {
return nil
}
return os.Chdir(exeDir)
}
func looksLikeProjectDirectory(dir string) bool {
for _, name := range []string{"go.mod", "config.yaml", "data", filepath.Join("web", "dist")} {
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
return true
}
}
return false
}
func samePath(left, right string) bool {
return strings.EqualFold(filepath.Clean(left), filepath.Clean(right))
}
type singleInstance struct {
handle windows.Handle
}
func acquireSingleInstance() (*singleInstance, error) {
name, err := windows.UTF16PtrFromString(singleInstanceName)
if err != nil {
return nil, err
}
handle, err := windows.CreateMutex(nil, false, name)
if errors.Is(err, windows.ERROR_ALREADY_EXISTS) {
if handle != 0 {
_ = windows.CloseHandle(handle)
}
return nil, errAlreadyRunning
}
if err != nil {
return nil, err
}
return &singleInstance{handle: handle}, nil
}
func (s *singleInstance) Close() error {
if s == nil || s.handle == 0 {
return nil
}
err := windows.CloseHandle(s.handle)
s.handle = 0
return err
}
func launchSelf() error {
exe, err := os.Executable()
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
cmd := exec.Command(exe, os.Args[1:]...)
cmd.Dir = cwd
cmd.Env = os.Environ()
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS,
}
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
func autoStartEnabled() bool {
key, err := registry.OpenKey(registry.CURRENT_USER, runRegistryKey, registry.QUERY_VALUE)
if err != nil {
return false
}
defer key.Close()
value, _, err := key.GetStringValue(runRegistryValue)
if err != nil {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(strings.Trim(value, `"`)), filepath.Clean(exe))
}
func setAutoStart(enabled bool) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, runRegistryKey, registry.SET_VALUE)
if err != nil {
return err
}
defer key.Close()
if !enabled {
if err := key.DeleteValue(runRegistryValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
return key.SetStringValue(runRegistryValue, syscall.EscapeArg(filepath.Clean(exe)))
}
func openURL(url string) error {
return shellOpen(url)
}
func openPath(path string) error {
return shellOpen(path)
}
func shellOpen(target string) error {
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
return err
}
verbPtr, err := windows.UTF16PtrFromString("open")
if err != nil {
return err
}
return windows.ShellExecute(0, verbPtr, targetPtr, nil, nil, 1)
}
func reportError(title string, err error) {
if err == nil {
return
}
text, textErr := windows.UTF16PtrFromString(title + "\r\n\r\n" + err.Error())
if textErr != nil {
return
}
caption, captionErr := windows.UTF16PtrFromString("MeBox")
if captionErr != nil {
return
}
_, _ = windows.MessageBox(0, text, caption, windows.MB_OK|windows.MB_ICONERROR|windows.MB_SETFOREGROUND)
}
+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)
}
})
}
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
package main
// Regenerate the linked Windows resources after changing the project logo or
// manifest:
//
// go generate ./cmd/server
//
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch amd64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_amd64.syso
//go:generate go run github.com/akavel/rsrc@v0.10.2 -arch arm64 -ico ../../internal/brand/logo.ico -manifest winres/mebox.manifest -o rsrc_windows_arm64.syso
+16 -6
View File
@@ -19,13 +19,16 @@ import (
"github.com/truewhile/MeBox/web"
)
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
func buildRouter(cfg *config.Config, logger *zap.Logger, embyCompatLogger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.EmbyCompatLogger(embyCompatLogger, func(path string) bool {
return !isFrontendLibraryRoute(path) && handler.IsEmbyPath(path)
}))
if !cfg.App.Debug && len(cfg.App.CORSOrigins) == 0 {
logger.Warn("CORS: no origins configured in production — CORS headers will be omitted (same-origin enforced). Set app.cors_origins for cross-origin access.")
}
@@ -59,6 +62,13 @@ func serveSPA(r *gin.Engine, root fs.FS) {
c.Next()
})
assets.GET("/*filepath", serveFSDir(root, "assets"))
fonts := r.Group("/fonts")
fonts.Use(middleware.GzipStatic())
fonts.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
c.Next()
})
fonts.GET("/*filepath", serveFSDir(root, "fonts"))
brand := r.Group("/brand")
brand.Use(func(c *gin.Context) {
setNoCacheHeaders(c)
@@ -70,11 +80,11 @@ func serveSPA(r *gin.Engine, root fs.FS) {
r.GET(rootFile, serveFSFile(root, name))
r.HEAD(rootFile, serveFSFile(root, name))
}
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
path := c.Request.URL.Path
r.NoRoute(middleware.GzipStatic(), func(c *gin.Context) {
if handler.TryHandleEmbyNormalizedRoute(c, r) {
return
}
path := c.Request.URL.Path
if shouldBypassSPAFallback(path) {
c.Status(http.StatusNotFound)
return
Binary file not shown.
Binary file not shown.
+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)
}
}
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="MeBox"
type="win32"
/>
<description>MeBox media server</description>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
+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.
+218
View File
@@ -0,0 +1,218 @@
# MeBox + MetaTube 一体部署 Docker Compose 部署文件
#
# 组件:
# MeBox + PostgreSQL + Redis + MetaTube
#
# 与 docker-compose.standard.yml 的区别:
# 额外内置一个 MetaTube 后端(番号元数据刮削 / 封面人脸裁剪),
# 并与 MeBox 共用同一个 PostgreSQL 实例,不需要再单独起一套数据库。
#
# 使用方式二选一:
# 1. 保存为 docker-compose.yml 后执行:
# docker compose up -d
# 2. 保留本文件名时执行:
# docker compose -f docker-compose.metatube.yml up -d
#
# 首次启动后需要做一次配置(只有这一处是手动的):
# 登录 MeBox → 设置 → 番号刮削:
# - 番号刮削引擎:MetaTube 后端服务,或 智能混合
# - MetaTube 服务端地址:http://metatube:8080
# - 访问 Token:留空(仅内部网络,未启用认证;如需认证两边填同一个值)
#
# 说明:
# - MetaTube 使用独立数据库 metatube,由 metatube-db-init 一次性容器自动创建;
# 和 MeBox 的 mebox 库共用同一个 PostgreSQL 实例,只是分成两个库。
# - MetaTube 不向宿主机映射端口,只在本 Compose 内部网络提供服务。
# - MeBox 故意不依赖 MetaTube:MetaTube 未就绪或挂掉时,智能混合模式会自动回退到内置刮削源。
# - 更新全部组件:
# docker compose -f docker-compose.metatube.yml pull
# docker compose -f docker-compose.metatube.yml up -d
# 保留本文件名时,管理面板「系统更新」需要把「Compose 安装目录」指向本文件所在目录,
# 或把更新命令改成带 -f docker-compose.metatube.yml 的形式。
#
# 默认账号:
# admin / admin123
services:
mebox:
image: ghcr.io/truewhile/mebox:latest
restart: unless-stopped
init: true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "18080:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
# 程序运行数据:JWT 密钥、运行配置、旧 SQLite 迁移源。
- ./data:/data
# 缓存目录:海报缓存、临时文件等。通常不用备份。
- ./cache:/cache
# 媒体库目录。自动整理/重命名/入库需要读写权限。
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media:/media
environment:
TZ: Asia/Shanghai
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
MEBOX_APP_WEB_DIR: /app/web/dist
MEBOX_APP_DATA_DIR: /data
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
MEBOX_DATABASE_DSN: postgres://mebox:mebox@postgres:5432/mebox?sslmode=disable
MEBOX_DATABASE_DB_PATH: /data/mebox.db
# Redis 只做热缓存,源数据仍在 PostgreSQL;Redis 丢失可自动重建。
MEBOX_CACHE_REDIS_URL: redis://redis:6379/0
MEBOX_CACHE_CACHE_DIR: /cache
MEBOX_UPDATE_IMAGE: ghcr.io/truewhile/mebox:latest
# 路径换算配置。左边宿主机真实路径要和 volumes 左边保持一致。
MEBOX_MEDIA_DIR: /media
MEBOX_MEDIA_CONTAINER_DIR: /media
MEBOX_DOWNLOAD_DIR: /downloads
MEBOX_DOWNLOAD_CONTAINER_DIR: /downloads
MEBOX_TRANSCODER_ENABLED: "true"
MEBOX_TRANSCODER_HARDWARE_ACCEL: "false"
MEBOX_TRANSCODER_REALTIME: "true"
MEBOX_TRANSCODER_THREADS: "2"
MEBOX_TRANSCODER_MAX_CONCURRENT: "1"
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
# 首次部署允许拉取;日常更新请只 pull mebox。
# MetaTube 与 MeBox 共用本实例(不同数据库),无需第二套 PostgreSQL。
pull_policy: missing
restart: unless-stopped
environment:
POSTGRES_DB: mebox
POSTGRES_USER: mebox
POSTGRES_PASSWORD: mebox
TZ: Asia/Shanghai
volumes:
# 同时存放 mebox 与 metatube 两个数据库,备份这一个目录即可。
- ./postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U mebox -d mebox"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
# 一次性容器:确保 metatube 库存在。
# MetaTube 自己的 -db-auto-migrate 只建表不建库,所以必须先建库。
# 数据库已存在时它会直接退出,重复执行无副作用。
metatube-db-init:
image: postgres:16-alpine
pull_policy: missing
restart: "no"
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: mebox
entrypoint: ["/bin/sh", "-c"]
command:
- |
psql -h postgres -U mebox -d mebox -tAc "SELECT 1 FROM pg_database WHERE datname='metatube'" | grep -q 1 || psql -h postgres -U mebox -d mebox -c "CREATE DATABASE metatube OWNER mebox"
logging:
driver: json-file
options:
max-size: "5m"
max-file: "1"
metatube:
image: ghcr.io/metatube-community/metatube-server:latest
restart: unless-stopped
depends_on:
metatube-db-init:
condition: service_completed_successfully
environment:
TZ: Asia/Shanghai
# 需要走代理时在这里填写,例如 http://192.168.1.2:7890
HTTP_PROXY: ""
HTTPS_PROXY: ""
# 不需要 ports:MeBox 通过内部网络访问 http://metatube:8080。
# 只有想让局域网内其他工具直连时才映射,且建议只映射到 127.0.0.1。
command:
- -dsn
- postgres://mebox:mebox@postgres:5432/metatube?sslmode=disable
- -port
- "8080"
- -db-auto-migrate
- -db-prepared-stmt
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
pull_policy: missing
restart: unless-stopped
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- 256mb
- --maxmemory-policy
- allkeys-lru
volumes:
- ./redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
logging:
driver: json-file
options:
max-size: "20m"
max-file: "3"
+13 -11
View File
@@ -81,8 +81,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -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
@@ -124,8 +126,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -147,8 +149,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -172,8 +174,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
opensearch:
image: opensearchproject/opensearch:2
@@ -196,5 +198,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+7 -5
View File
@@ -64,8 +64,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
extra_hosts:
@@ -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
@@ -82,5 +84,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+14 -29
View File
@@ -44,30 +44,13 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- type: bind
source: ./media
target: /media
bind:
create_host_path: false
# 下载目录。需要和下载器保存路径保持一致。
# NAS 示例:source: /vol1/1000/Downloads
# Windows Docker Desktop 示例:source: D:/Downloads
- type: bind
source: ./downloads
target: /downloads
bind:
create_host_path: false
# 管理面板「系统更新」需要访问 Docker 引擎。
# 需要一键更新 Docker 镜像时取消下一行注释。
# - /var/run/docker.sock:/var/run/docker.sock
- ./media:/media
environment:
TZ: Asia/Shanghai
PUID: "1000"
PGID: "1000"
PUID: "0"
PGID: "0"
MEBOX_APP_HOST: 0.0.0.0
MEBOX_APP_PORT: 8080
@@ -76,8 +59,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
MEBOX_DATABASE_TYPE: postgres
@@ -104,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
@@ -113,8 +98,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -136,8 +121,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
redis:
image: redis:7-alpine
@@ -161,5 +146,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+9 -7
View File
@@ -92,8 +92,8 @@ services:
MEBOX_LOGGING_LEVEL: info
MEBOX_LOGGING_FORMAT: console
MEBOX_LOGGING_OUTPUT_PATH: /data/logs
MEBOX_LOGGING_MAX_SIZE_MB: "50"
MEBOX_LOGGING_MAX_BACKUPS: "20"
MEBOX_LOGGING_MAX_SIZE_MB: "20"
MEBOX_LOGGING_MAX_BACKUPS: "5"
MEBOX_LOGGING_MAX_AGE_DAYS: "30"
# 轻量模式默认只使用 PostgreSQL,适合大多数 NAS。
@@ -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
@@ -141,8 +143,8 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
postgres:
image: postgres:16-alpine
@@ -166,5 +168,5 @@ services:
logging:
driver: json-file
options:
max-size: "50m"
max-file: "10"
max-size: "20m"
max-file: "3"
+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/字段一一对应。
@@ -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

+25 -11
View File
@@ -1,8 +1,9 @@
module github.com/truewhile/MeBox
go 1.25.0
go 1.26.0
require (
fyne.io/systray v1.12.2
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2
github.com/fsnotify/fsnotify v1.7.0
github.com/gin-contrib/gzip v1.2.6
@@ -12,15 +13,16 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/metatube-community/metatube-sdk-go v1.4.0
github.com/redis/go-redis/v9 v9.7.0
github.com/redis/go-redis/v9 v9.7.3
github.com/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.12
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/sys v0.42.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.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
gorm.io/driver/postgres v1.6.0
@@ -28,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
@@ -36,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
@@ -45,12 +56,16 @@ 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
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -68,7 +83,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -87,9 +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/sync v0.20.0 // indirect
golang.org/x/text v0.35.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
+121 -20
View File
@@ -1,5 +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=
@@ -22,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=
@@ -52,13 +73,21 @@ 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=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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=
@@ -77,8 +106,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -121,10 +150,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -171,8 +200,9 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
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.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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=
@@ -187,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.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
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=
+10
View File
@@ -0,0 +1,10 @@
// Package brand contains the embedded MeBox application artwork.
package brand
import _ "embed"
// Icon is the Windows ICO used by both the executable resource and the
// notification-area icon.
//
//go:embed logo.ico
var Icon []byte
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+6 -2
View File
@@ -46,6 +46,9 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Cache.MediaTTLSeconds != 90 {
t.Fatalf("expected default media cache ttl 90, got %d", cfg.Cache.MediaTTLSeconds)
}
if cfg.Cache.MemoryMaxSizeMB != DefaultCacheMemoryMaxSizeMB {
t.Fatalf("expected default runtime cache memory limit %d, got %d", DefaultCacheMemoryMaxSizeMB, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Index != "mebox_media" {
t.Fatalf("expected default search index, got %q", cfg.Search.Index)
}
@@ -88,6 +91,7 @@ func TestEnvOverride(t *testing.T) {
t.Setenv("MEBOX_DATABASE_DSN", "postgres://mebox:secret@postgres:5432/mebox?sslmode=disable")
t.Setenv("MEBOX_CACHE_REDIS_URL", "redis://redis:6379/0")
t.Setenv("MEBOX_CACHE_MEDIA_TTL_SECONDS", "30")
t.Setenv("MEBOX_CACHE_MEMORY_MAX_SIZE_MB", "48")
t.Setenv("MEBOX_SEARCH_BACKEND", "opensearch")
t.Setenv("MEBOX_SEARCH_OPENSEARCH_URL", "http://opensearch:9200")
t.Setenv("MEBOX_LICENSE_SERVER_URL", "https://license.example.com")
@@ -103,8 +107,8 @@ func TestEnvOverride(t *testing.T) {
if cfg.Database.Type != "postgres" || cfg.Database.DSN == "" {
t.Fatalf("expected postgres database config from env, got type=%q dsn=%q", cfg.Database.Type, cfg.Database.DSN)
}
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds)
if cfg.Cache.RedisURL != "redis://redis:6379/0" || cfg.Cache.MediaTTLSeconds != 30 || cfg.Cache.MemoryMaxSizeMB != 48 {
t.Fatalf("expected redis cache config from env, got url=%q ttl=%d memory=%d", cfg.Cache.RedisURL, cfg.Cache.MediaTTLSeconds, cfg.Cache.MemoryMaxSizeMB)
}
if cfg.Search.Backend != "opensearch" || cfg.Search.OpenSearchURL != "http://opensearch:9200" {
t.Fatalf("expected opensearch config from env, got backend=%q url=%q", cfg.Search.Backend, cfg.Search.OpenSearchURL)
+9 -2
View File
@@ -7,6 +7,7 @@ const (
defaultDatabaseMaxIdleConns = 4
defaultLicenseServerURL = "https://mgosever.3jzs.com"
defaultLicensePublicKey = "MCowBQYDK2VwAyEABRXnXy+urjrbKit6Yu/HiezWgP0NdsZW3tsegJWRrtI="
DefaultCacheMemoryMaxSizeMB = 128
)
func setDefaults(v *viper.Viper) {
@@ -32,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", "")
@@ -43,11 +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.cleanup_interval_min", 60)
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.redis_url", "")
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
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", "")
+15
View File
@@ -47,12 +47,27 @@ 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
}
if c.Cache.RedisPrefix == "" {
c.Cache.RedisPrefix = "mebox"
}
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"
+35 -11
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 密钥(不要提交值)。
@@ -113,17 +118,36 @@ type LoggingConfig struct {
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig 控制磁盘转码/刮削缓存。
// CacheConfig 控制磁盘转写缓存和进程内热缓存。
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
ImagesMaxSizeMB int `mapstructure:"images_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"`
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"`
RedisURL string `mapstructure:"redis_url"`
RedisPrefix string `mapstructure:"redis_prefix"`
MediaTTLSeconds int `mapstructure:"media_ttl_seconds"`
// EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。
// 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 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)
}
})
})
}
}
+6 -1
View File
@@ -127,5 +127,10 @@ func (z zapStdLogger) Printf(format string, args ...interface{}) {
if z.log == nil {
return
}
z.log.Sugar().Infof(format, args...)
message := fmt.Sprintf(format, args...)
if strings.Contains(strings.ToLower(message), "context canceled") {
z.log.Debug(message)
return
}
z.log.Info(message)
}
+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)
}
}
+298 -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,8 +376,18 @@ 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`,
`CREATE INDEX IF NOT EXISTS idx_media_library_tmdb_active ON media(library_id, tm_db_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_bangumi_active ON media(library_id, bangumi_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_douban_active ON media(library_id, douban_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_media_library_thetvdb_active ON media(library_id, thetvdb_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_favorites_user_media_active ON favorites(user_id, media_id) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_user_media_active ON playback_histories(user_id, media_id, watched_at DESC) WHERE deleted_at IS NULL`,
`CREATE INDEX IF NOT EXISTS idx_playback_histories_resume_active ON playback_histories(user_id, completed, watched_at DESC) 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")
}
}
+29 -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,13 @@ 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)
}
c.Status(http.StatusNoContent)
}
+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())
}
}
+181
View File
@@ -0,0 +1,181 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
func mediaPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
quality, _ := strconv.Atoi(c.Query("quality"))
info, err := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, quality)
if err != nil {
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func mediaTranscodeHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
var req struct {
Definition int `json:"definition"`
}
_ = c.ShouldBindJSON(&req)
info, err := svc.Cloud115.StartTranscode(c.Request.Context(), m.ID, req.Definition)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, info)
}
}
func cloud115HLSMasterHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if !enforceScopedPlaybackToken(c, m.ID) {
return
}
definition, _ := strconv.Atoi(c.Query("definition"))
err = svc.Cloud115.HLSProxy().ServeMaster(c.Request.Context(), c.Writer, c.Request, m.ID, definition)
if err == nil {
return
}
if c.Writer.Written() {
return
}
if errors.Is(err, service.ErrCloud115TranscodePending) {
retryAfter := 5
message := "115 正在转码,请稍候"
var info *service.PlaybackInfo
if latest, infoErr := svc.Cloud115.PlaybackInfo(c.Request.Context(), m.ID, definition); infoErr == nil && latest != nil {
info = latest
if latest.Transcode.Message != "" {
message = latest.Transcode.Message
}
if latest.Transcode.RetryAfterSec > 0 {
retryAfter = latest.Transcode.RetryAfterSec
}
}
c.JSON(http.StatusConflict, gin.H{
"code": "transcode_pending",
"message": message,
"retry_after_sec": retryAfter,
"playback": info,
})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc == nil || svc.Cloud115 == nil || svc.Cloud115.HLSProxy() == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud115 playback unavailable"})
return
}
mediaID := c.Query("media_id")
if mediaID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing media_id"})
return
}
if c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
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,
c.Param("session"),
c.Param("key"),
)
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):
// 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{"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})
}
}
+36 -5
View File
@@ -2,7 +2,9 @@
package handler
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -14,7 +16,11 @@ import (
// specific danmaku library chosen by the user after a disambiguation.
func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := svc.Danmaku.Fetch(c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"))
uid := currentUserID(c)
// 按用户读取弹幕源、凭据、合并偏好和渲染参数。
opts := service.DanmakuFetchOptions{UserID: uid}
res, err := svc.Danmaku.FetchWithOptions(
c.Request.Context(), c.Param("id"), c.Query("kw"), c.Query("episodeId"), opts)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -23,11 +29,36 @@ func getDanmakuHandler(svc *service.Container) gin.HandlerFunc {
}
}
// getDanmakuConfigHandler exposes the danmaku renderer knobs (opacity, font
// size, area, enabled) so the player can initialize its control panel without
// admin privileges.
// getDanmakuConfigHandler exposes the current user's player volume and danmaku
// preferences so the player can initialize without admin privileges.
func getDanmakuConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Danmaku.Config(c.Request.Context()))
c.JSON(http.StatusOK, svc.Danmaku.ConfigForUser(c.Request.Context(), currentUserID(c)))
}
}
// updateDanmakuSettingsHandler 持久化当前用户的播放器音量与弹幕偏好。
func updateDanmakuSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := currentUserID(c)
if strings.TrimSpace(uid) == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
var req service.DanmakuSettingsPatch
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
cfg, err := svc.Danmaku.UpdateUserSettings(c.Request.Context(), uid, req)
if errors.Is(err, service.ErrNoDanmakuSettings) || errors.Is(err, service.ErrInvalidDanmakuSettings) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
}
+256
View File
@@ -0,0 +1,256 @@
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/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newDanmakuSettingsContext 构造带登录用户的最小 gin 上下文。
func newDanmakuSettingsContext(t *testing.T, svc *service.Container, method, path, body string, userID string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, path, bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
if userID != "" {
c.Set(middleware.CtxUserID, userID)
}
return c, w
}
func newDanmakuSettingsService(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.Setting{}, &model.Media{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
user := model.User{Username: "settings-user", PasswordHash: "x", Role: "user", IsActive: true}
user.ID = "user-1"
if err := repos.User.Create(t.Context(), &user); err != nil {
t.Fatalf("create user: %v", err)
}
return &service.Container{
Repo: repos,
Danmaku: service.NewDanmakuService(zap.NewNop(), repos),
}
}
func TestUpdateDanmakuSettingsPersistsMergeSources(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"merge_sources":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var resp struct {
MergeSources bool `json:"merge_sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.MergeSources {
t.Fatal("response should echo merge_sources=true")
}
// 落库校验:重新读取应为 true。
if !svc.Danmaku.MergeSourcesEnabled(t.Context(), "user-1") {
t.Fatal("merge preference was not persisted")
}
}
func TestUpdateDanmakuSettingsRejectsMissingField(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings", `{}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.Body.String())
}
}
func TestUpdateDanmakuSettingsRequiresAuthentication(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"merge_sources":true}`, "")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 (body=%s)", w.Code, w.Body.String())
}
}
// config 接口应把当前用户的合并偏好带出去,供面板初始化。
func TestGetDanmakuConfigIncludesPerUserMergePreference(t *testing.T) {
svc := newDanmakuSettingsService(t)
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", w.Code)
}
var cfg struct {
MergeSources bool `json:"merge_sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if cfg.MergeSources {
t.Fatal("default merge preference should be false")
}
if err := svc.Danmaku.SetMergeSources(t.Context(), "user-1", true); err != nil {
t.Fatalf("set: %v", err)
}
c2, w2 := newDanmakuSettingsContext(t, svc, http.MethodGet, "/danmaku/config", "", "user-1")
getDanmakuConfigHandler(svc)(c2)
if err := json.Unmarshal(w2.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.MergeSources {
t.Fatal("config should reflect the persisted merge preference")
}
}
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,\"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)
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("my-app-secret")) {
t.Fatal("response must never expose the application secret")
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
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 {
t.Fatalf("unexpected user preferences: %+v", cfg)
}
if cfg.Source != "https://dm.example/base" || cfg.AppID != "my-app-id" || !cfg.AppKeyConfigured {
t.Fatalf("unexpected service config: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if user.DanmakuAppKey != "my-app-secret" || user.PlayerVolume != 0.35 || user.DanmakuSource != "https://dm.example/base" {
t.Fatalf("preferences not persisted: %+v", user)
}
}
func TestUpdateDanmakuSettingsPersistsVr360GuideSeen(t *testing.T) {
svc := newDanmakuSettingsService(t)
// 默认未看过:配置接口必须先如实告知前端,否则首次操作说明会被跳过。
initial := svc.Danmaku.ConfigForUser(t.Context(), "user-1")
if initial.Vr360GuideSeen {
t.Fatal("vr360 guide should be unread for a fresh user")
}
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"vr360_guide_seen":true}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
var cfg service.DanmakuRenderConfig
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if !cfg.Vr360GuideSeen {
t.Fatalf("config should reflect the persisted guide flag: %+v", cfg)
}
user, err := svc.Repo.User.FindByID(t.Context(), "user-1")
if err != nil || user == nil {
t.Fatalf("read persisted user: %v", err)
}
if !user.PlayerVr360GuideSeen {
t.Fatalf("guide flag not persisted on the user: %+v", user)
}
}
// 播放倍速只作用于当前视频:接口既不下发也不保存它。老客户端仍可能带上
// playback_rate,服务端应当忽略而不是报错。
func TestDanmakuSettingsIgnorePlaybackRate(t *testing.T) {
svc := newDanmakuSettingsService(t)
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())
}
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.PlayerVolume != 0.5 {
t.Fatalf("volume not persisted: %v", user.PlayerVolume)
}
}
func TestUpdateDanmakuSettingsRejectsInvalidSource(t *testing.T) {
svc := newDanmakuSettingsService(t)
c, w := newDanmakuSettingsContext(t, svc, http.MethodPut, "/danmaku/settings",
`{"source":"ftp://dm.example.com"}`, "user-1")
updateDanmakuSettingsHandler(svc)(c)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", w.Code, w.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.DanmakuSource != "" {
t.Fatalf("invalid source was persisted: %q", user.DanmakuSource)
}
}
+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)
}
}
+7
View File
@@ -26,6 +26,13 @@ func embyUserID(c *gin.Context) string {
return ""
}
// embyEffectiveUserID 返回当前鉴权用户。收藏、续播、播放进度等用户态数据必须
// 绑定到登录身份;路径/Query 里的 UserId 只是 Emby URL 形态兼容,不能覆盖鉴权结果,
// 否则多用户会串到同一个 UserId 上。
func embyEffectiveUserID(c *gin.Context) string {
return embyUserID(c)
}
const embyCompatSessionTTL = 30 * time.Minute
type embyCompatSession struct {
@@ -0,0 +1,121 @@
package handler
import (
"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/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// newEmbyCompatTestRouter 构造一个挂载了完整 Emby 路由表的测试引擎。
func newEmbyCompatTestRouter(t *testing.T, secret string) *gin.Engine {
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.AllModels()...); err != nil {
t.Fatalf("migrate: %v", 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.Fatalf("create user: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
})
return router
}
func TestEmbyAdditionalPartsReturnsEmptyArray(t *testing.T) {
const secret = "test-secret"
router := newEmbyCompatTestRouter(t, secret)
req := httptest.NewRequest(http.MethodGet, "/emby/Videos/msgo-series-abc/AdditionalParts", nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
// 必须命中 AdditionalParts 静态路由并返回空数组,而不是被 /Videos/:id/:seg
// 的 HLS 兜底路由接走返回空 404。
var payload []any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body %q: %v", w.Body.String(), err)
}
if len(payload) != 0 {
t.Fatalf("expected an empty array, got %v", payload)
}
}
func TestEmbyItemImagesWithoutTypeReturnsArray(t *testing.T) {
router := newEmbyCompatTestRouter(t, "test-secret")
// 不带 Type 的图片清单接口是公开路由,不要求 token,与带 Type 的
// 图片字节流一致(客户端缓存 URL 时会丢 token)。
req := httptest.NewRequest(http.MethodGet, "/emby/Items/unknown-item/Images", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct == "" || ct[:16] != "application/json" {
t.Fatalf("content type = %q, want application/json", ct)
}
var payload []any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body %q: %v", w.Body.String(), err)
}
}
func TestEmbyItemImagesLowerCaseRouteIsRegistered(t *testing.T) {
router := newEmbyCompatTestRouter(t, "test-secret")
req := httptest.NewRequest(http.MethodGet, "/emby/items/unknown-item/images", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
}
func TestEmbyUserImageWithoutAvatarReturnsCacheableNotFound(t *testing.T) {
router := newEmbyCompatTestRouter(t, "test-secret")
req := httptest.NewRequest(http.MethodGet, "/emby/Users/user-1/Images/Primary", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// 用户没有头像时 Emby 同样返回 404,但响应必须可缓存,否则客户端会
// 在每次进入设置页时重复请求(线上曾观察到每分钟一次的重试)。
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 (body=%s)", w.Code, w.Body.String())
}
if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=86400" {
t.Fatalf("Cache-Control = %q, want the cacheable directive", cc)
}
}
+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))
}
}
+96 -17
View File
@@ -29,25 +29,104 @@ var embyPlaceholderPNG = []byte{
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
id := c.Param("id")
imgType := strings.ToLower(c.Param("type"))
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"))
}
}
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"))
}
}
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) {
clearEmbyImageNoStoreHeaders(c)
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req := c.Request.WithContext(ctx)
if svc == nil || svc.Emby == nil {
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.
raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag)
if err != nil || raw == "" {
embyServePlaceholderImage(c)
return
}
if svc.ImageProxy == nil {
embyServePlaceholderImage(c)
return
}
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
embyServePlaceholderImage(c)
}
}
// embyItemImagesHandler 处理不带 Type 的 GET /Items/{Id}/Images,返回图片
// 清单(Emby 的 ImageInfo 数组)。客户端据此决定详情页加载哪些图。
func embyItemImagesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if svc == nil || svc.Emby == nil || id == "" {
c.JSON(http.StatusOK, []any{})
return
}
infos := svc.Emby.ImageInfos(c.Request.Context(), id)
if infos == nil {
infos = []map[string]any{}
}
c.JSON(http.StatusOK, infos)
}
}
// embyUserImageHandler 处理 /Users/{UserId}/Images/{Type}。Emby 对未设置
// 头像的用户同样返回 404,但响应必须带缓存头,否则客户端每次进入设置页
// 都会重复请求同一个空头像(日志中曾观察到每分钟重试)。
func embyUserImageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := strings.TrimSpace(c.Param("userId"))
raw := ""
if svc != nil && svc.Emby != nil && uid != "" {
raw = svc.Emby.UserAvatarURL(c.Request.Context(), uid)
}
if raw == "" || svc == nil || svc.ImageProxy == nil {
embyMissingAvatar(c)
return
}
if err := svc.ImageProxy.Serve(c.Request.Context(), c.Writer, c.Request, raw); err != nil {
embyMissingAvatar(c)
}
}
}
// embyMissingAvatar 以 Emby 语义返回"该用户没有头像",并允许客户端长期缓存。
func embyMissingAvatar(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=86400")
c.Status(http.StatusNotFound)
}
func clearEmbyImageNoStoreHeaders(c *gin.Context) {
+51 -36
View File
@@ -13,13 +13,7 @@ import (
func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "50"))
offset, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
splitOpt := func(s string) []string {
if s == "" {
return nil
@@ -46,9 +40,28 @@ func parseEmbyItemsParams(c *gin.Context) service.ItemsParams {
SortOrder: firstQueryValue(c, "SortOrder", "sortOrder", "sortorder"),
Limit: limit,
StartIndex: offset,
SeasonIndex: parseEmbySeasonIndexQuery(c),
}
}
// parseEmbySeasonIndexQuery 读取客户端请求的季序号。
//
// Emby 客户端有两种表达方式:SeasonId(虚拟季 ID)与 Season / SeasonIndex
// (季序号,特别篇为 0)。两者都是合法入参,SeasonId 更精确。这里只解析季序号,
// 返回 nil 表示客户端没有按季过滤(区别于 Season=0 的特别篇)。
func parseEmbySeasonIndexQuery(c *gin.Context) *int {
raw := firstQueryValue(c, "Season", "season", "SeasonIndex", "seasonIndex", "seasonindex")
if raw == "" {
return nil
}
value, err := strconv.Atoi(raw)
if err != nil {
// 客户端偶尔传入季名称之类的非数字值;按「未过滤」处理,避免整季空结果。
return nil
}
return &value
}
func embyFirstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -73,10 +86,7 @@ func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemByIDHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
uid := c.Param("userId")
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.Item(c.Request.Context(), id, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -106,13 +116,7 @@ func embyUserItemByIDHandler(svc *service.Container) gin.HandlerFunc {
func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
out, err := svc.Emby.LatestItems(c.Request.Context(), uid, firstQueryValue(c, "ParentId", "parentId", "parentid"), limit)
if err != nil {
@@ -126,15 +130,12 @@ func embyLatestItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := c.Param("userId")
if uid == "" {
uid = firstQueryValue(c, "UserId", "userId", "userid")
}
if uid == "" {
uid = embyUserID(c)
}
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
@@ -147,13 +148,7 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
func embyItemsCountsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if svc != nil && svc.Emby != nil {
uid := firstQueryValue(c, "UserId", "userId")
if uid == "" {
uid = c.Param("userId")
}
if uid == "" {
uid = embyUserID(c)
}
uid := embyEffectiveUserID(c)
out, err := svc.Emby.ItemCounts(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -209,7 +204,7 @@ func embySaveDisplayPreferencesHandler(_ *service.Container) gin.HandlerFunc {
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
params := service.ItemsParams{
UserID: firstQueryValue(c, "UserId", "userId"),
UserID: embyEffectiveUserID(c),
ParentID: c.Param("id"),
Limit: 500,
}
@@ -226,15 +221,35 @@ func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
func embyShowEpisodesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
parentID := firstQueryValue(c, "SeasonId", "seasonId")
// 客户端常用季序号而不是虚拟季 ID 请求剧集。缺了这层过滤,
// /Shows/{id}/Episodes?Season=2 会把整部剧的所有季都返回。
seasonIndex := parseEmbySeasonIndexQuery(c)
if parentID == "" {
parentID = c.Param("id")
} else {
// SeasonId 已经限定了具体季,忽略同时传来的季序号,避免两者
// 不一致时把结果过滤成空集。
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: firstQueryValue(c, "UserId", "userId"),
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)
if err != nil {
+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)
}
}
+4 -1
View File
@@ -68,7 +68,7 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
// 1. 测试上报进度:客户端使用小写 query 参数 itemId / positionTicks
progressReq := httptest.NewRequest(
http.MethodPost,
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000",
"/Sessions/Playing/Progress?itemId="+remoteMediaID+"&positionTicks=300000000&runTimeTicks=1000000000&playSessionId=remote-mount-1-2000000000000",
nil,
)
wProgress := httptest.NewRecorder()
@@ -85,6 +85,9 @@ func TestMountedEmbyPlayingProgressAndResumePipeline(t *testing.T) {
if hist.PositionMs != 30000 {
t.Fatalf("expected position_ms = 30000, got %d", hist.PositionMs)
}
if hist.SessionID != "remote-mount-1-2000000000000" || hist.SessionStartedAtMs != 2000000000000 {
t.Fatalf("unexpected playback session metadata: %#v", hist)
}
// 2. 测试 Filters=IsResumable 能够包含该远程条目
resumableReq := httptest.NewRequest(
+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())
}
}

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